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>
这个提交包含在:
+451
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div class="page-about">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '关于呼籁旅行'"
|
||||
subtitle="一个深耕呼伦贝尔的旅行品牌"
|
||||
:breadcrumbs="[{ text: '关于我们' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="关于我们摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行创立于2015年,是内蒙古呼伦贝尔本地旅行品牌,11年专注呼伦贝尔一个目的地。旗下持有三重旅行社经营许可证,购买1000万旅游责任险。集团旗下涵盖旅行社、旅游客运、露营地、牧场商贸、研学教育等9家主体。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 1. 品牌故事:为什么只做呼伦贝尔 -->
|
||||
<AboutBrandStory v-if="about" :story="about.story" class="reveal" />
|
||||
|
||||
<!-- 2. 服务旅程:跟呼籁出行是什么体验 -->
|
||||
<section v-if="about" class="section section--gray reveal" aria-label="服务旅程">
|
||||
<div class="container">
|
||||
<CommonSectionTitle :title="about.serviceJourney.title" :subtitle="about.serviceJourney.subtitle" />
|
||||
<ol class="journey-list">
|
||||
<li v-for="m in about.serviceJourney.moments" :key="m.step" class="journey-item">
|
||||
<span class="journey-step">{{ m.step }}</span>
|
||||
<div class="journey-body">
|
||||
<strong class="journey-name">{{ m.name }}</strong>
|
||||
<p class="journey-detail">{{ m.detail }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. 团队介绍:谁在照顾你 -->
|
||||
<AboutTeamIntro v-if="about" :team="about.team" class="reveal" />
|
||||
|
||||
<!-- 4. 小红书 -->
|
||||
<AboutXiaohongshuShowcase v-if="about" :xhs="about.xiaohongshu" class="reveal" />
|
||||
|
||||
<!-- 6. 品牌发展历程 -->
|
||||
<section v-if="about" class="section reveal" aria-label="品牌发展历程">
|
||||
<div class="container">
|
||||
<CommonSectionTitle :title="about.heritage.title" subtitle="2015年创立至今,一步一个脚印走在呼伦贝尔草原上" />
|
||||
<div class="timeline">
|
||||
<div v-for="milestone in about.heritage.milestones" :key="milestone.year" class="timeline-item">
|
||||
<div class="timeline-marker">
|
||||
<div class="timeline-dot" />
|
||||
</div>
|
||||
<div class="timeline-card">
|
||||
<h4 class="timeline-year">{{ milestone.year }} 年</h4>
|
||||
<p v-for="(p, i) in milestone.paragraphs" :key="i" class="timeline-text">{{ p }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7. 集团企业 -->
|
||||
<AboutSubsidiaryGrid v-if="about" :subsidiaries="about.subsidiaries" class="reveal" />
|
||||
|
||||
<!-- 8. 品牌资产:知识产权 + 吉祥物 -->
|
||||
<AboutCopyrightList v-if="about" :copyrights="about.copyrights" :trademark="about.trademark" class="reveal" />
|
||||
<AboutMascotShowcase class="reveal" />
|
||||
|
||||
<!-- 9. 企业文化 -->
|
||||
<section v-if="about" class="section reveal" aria-label="企业文化">
|
||||
<div class="container">
|
||||
<!-- 核心信念 -->
|
||||
<div class="culture-belief">
|
||||
<p class="culture-belief__prefix">我们深知</p>
|
||||
<div class="culture-belief__divider"></div>
|
||||
<p class="culture-belief__quote">大部分人只来一次呼伦贝尔</p>
|
||||
<p class="culture-belief__quote">而这一次便是一辈子的回忆</p>
|
||||
<div class="culture-belief__divider"></div>
|
||||
<p class="culture-belief__cta">所以,我们从不敢有任何的怠慢</p>
|
||||
</div>
|
||||
|
||||
<!-- 价值观 -->
|
||||
<CommonSectionTitle title="呼籁文化理念" />
|
||||
<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>
|
||||
<ul class="culture-value-card__list">
|
||||
<li v-for="(point, i) in value.points" :key="i">{{ point }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 品牌 Slogan -->
|
||||
<div v-if="about.culture.slogan" class="culture-slogan">
|
||||
<div v-for="(line, i) in about.culture.slogan.lines" :key="i" class="culture-slogan__line">
|
||||
<span v-if="line.label" class="culture-slogan__label">{{ line.label }}</span>
|
||||
<p class="culture-slogan__text">{{ line.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<CommonSectionCTA
|
||||
title="想了解我们的产品?"
|
||||
description="查看额吉的故乡V9系列和小蒙马夏令营"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看旅行产品' },
|
||||
{ to: '/contact', text: '联系定制师' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOrganizationSchema, useBreadcrumbSchema, usePersonSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: about } = await usePublicApi('about')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
|
||||
const seo = useSEO('about')
|
||||
useScrollReveal()
|
||||
useOrganizationSchema(brand.value, contact.value)
|
||||
usePersonSchema(about.value?.founder)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '关于我们' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 服务旅程 =====
|
||||
.journey-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: @space-xl 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.journey-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: @space-xl @space-lg;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-2px);
|
||||
border-color: @color-primary-lighter;
|
||||
|
||||
.journey-step {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
border-color: @color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.journey-step {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: transparent;
|
||||
color: @color-primary;
|
||||
border: 2px solid @color-primary;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
margin-bottom: @space-md;
|
||||
transition: all @transition-base;
|
||||
}
|
||||
|
||||
.journey-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.journey-name {
|
||||
display: block;
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
.journey-detail {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// ===== 发展历程 =====
|
||||
.timeline {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
margin: @space-xl auto 0;
|
||||
padding-left: @space-xl;
|
||||
|
||||
// 左侧竖线
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 8px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: @color-primary-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: @space-lg;
|
||||
padding-bottom: @space-2xl;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
|
||||
// 最后一项不画线到底
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: -@space-xl + 1px;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: @color-primary;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
flex: 1;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-lg @space-xl;
|
||||
box-shadow: @shadow-xs;
|
||||
transition: box-shadow @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-card;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-year {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
.timeline-text {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
margin: 0 0 @space-md;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 企业文化 =====
|
||||
.culture-belief {
|
||||
background: linear-gradient(135deg, @color-primary-dark 0%, @color-primary 60%, @color-primary-light 100%);
|
||||
color: #fff;
|
||||
border-radius: @border-radius-xl;
|
||||
padding: @space-3xl @space-2xl;
|
||||
margin-bottom: @space-2xl;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.respond-md({
|
||||
padding: @space-4xl @space-3xl;
|
||||
});
|
||||
|
||||
&__prefix {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
letter-spacing: 6px;
|
||||
color: #fff;
|
||||
margin: 0 0 @space-lg;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-xl;
|
||||
letter-spacing: 8px;
|
||||
});
|
||||
}
|
||||
|
||||
&__divider {
|
||||
width: 48px;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
margin: @space-lg auto;
|
||||
}
|
||||
|
||||
&__quote {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
line-height: @line-height-relaxed;
|
||||
margin: 0 0 @space-sm;
|
||||
letter-spacing: 2px;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-3xl;
|
||||
letter-spacing: 3px;
|
||||
});
|
||||
}
|
||||
|
||||
&__cta {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-medium;
|
||||
letter-spacing: 3px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-xl;
|
||||
letter-spacing: 4px;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
.culture-values {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
margin-top: @space-xl;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.culture-value-card {
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
margin: 0 0 @space-md;
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
|
||||
li {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
padding-left: @space-lg;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: @color-primary-lighter;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.culture-slogan {
|
||||
margin-top: @space-3xl;
|
||||
text-align: center;
|
||||
padding: @space-2xl 0;
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
&__line {
|
||||
margin-bottom: @space-xl;
|
||||
padding-bottom: @space-xl;
|
||||
border-bottom: 1px solid @color-divider;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-block;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
line-height: @line-height-normal;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-2xl;
|
||||
});
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/articles')">最新动态</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增动态' : '编辑动态' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/articles')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<div class="form-layout">
|
||||
<div class="form-main">
|
||||
<n-card title="基本信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入文章标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Slug" required>
|
||||
<n-input v-model:value="form.slug" placeholder="URL 标识,如 news-2026-spring" />
|
||||
<template #feedback>用于生成文章链接,仅限英文、数字和短横线</template>
|
||||
</n-form-item>
|
||||
<n-form-item label="摘要">
|
||||
<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" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<n-card title="发布设置">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分类">
|
||||
<n-select v-model:value="form.category" :options="[{ label: '产品', value: '产品' }, { label: '公告', value: '公告' }, { label: '活动', value: '活动' }]" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="封面图">
|
||||
<AdminImageUpload v-model="form.coverImage" />
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="!isNew" title="其他信息" size="small">
|
||||
<n-descriptions :column="1" label-placement="left" size="small">
|
||||
<n-descriptions-item label="创建时间">{{ form.createdAt?.slice(0, 16).replace('T', ' ') }}</n-descriptions-item>
|
||||
<n-descriptions-item label="更新时间">{{ form.updatedAt?.slice(0, 16).replace('T', ' ') }}</n-descriptions-item>
|
||||
<n-descriptions-item label="ID">{{ route.params.id }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/articles')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NDescriptions, NDescriptionsItem, NAlert } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({
|
||||
title: '', slug: '', category: '产品', summary: '', content: '',
|
||||
coverImage: '', published: true, createdAt: '', updatedAt: '',
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/articles/${route.params.id}`)
|
||||
form.value = { ...data, published: !!data.published, coverImage: data.coverImage || '' }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim()) { formError.value = '标题不能为空'; return }
|
||||
if (!form.value.slug?.trim()) { formError.value = 'Slug 不能为空'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/articles', { method: 'POST', body: { ...form.value, type: 'news' } })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/articles'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/articles/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-layout { display: grid; grid-template-columns: 1fr 300px; gap: 20px; align-items: start; }
|
||||
.form-main { min-width: 0; }
|
||||
.form-sidebar { display: flex; flex-direction: column; gap: 16px; }
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理官网新闻动态 · 对应官网「最新动态」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/news" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/articles/new')">新增动态</n-button>
|
||||
</n-space>
|
||||
</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()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<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-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
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 columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '标题', key: 'title',
|
||||
render: (row) => h('a', {
|
||||
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
|
||||
onClick: () => navigateTo(`/admin/articles/${row.id}`),
|
||||
}, row.title),
|
||||
},
|
||||
{
|
||||
title: '分类', key: 'category', width: 80,
|
||||
render: (row) => row.category ? h(NTag, { size: 'small', type: 'info', bordered: false }, () => row.category) : '-',
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small', type: row.published ? 'success' : 'default', bordered: false,
|
||||
style: 'cursor: pointer',
|
||||
onClick: () => togglePublish(row),
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '创建时间', key: 'createdAt', width: 100,
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.createdAt?.slice(0, 10)),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/articles/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/articles?type=news&page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
|
||||
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
|
||||
} catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/articles/${item.id}`, { method: 'PUT', body: { published: !item.published } })
|
||||
item.published = !item.published
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/articles/${item.id}`, { method: 'DELETE' })
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
} catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/blogs')">博客文章</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增博客' : '编辑博客' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/blogs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<div class="form-layout">
|
||||
<div class="form-main">
|
||||
<n-card title="基本信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入博客标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Slug" required>
|
||||
<n-input v-model:value="form.slug" placeholder="如 hulunbuir-travel-guide" />
|
||||
<template #feedback>用于生成博客链接</template>
|
||||
</n-form-item>
|
||||
<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>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="SEO 设置" style="margin-top: 16px">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="SEO 标题">
|
||||
<n-input v-model:value="form.seoTitle" placeholder="留空则使用文章标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="SEO 描述">
|
||||
<n-input v-model:value="form.seoDesc" type="textarea" :rows="2" placeholder="搜索引擎描述" />
|
||||
</n-form-item>
|
||||
<n-form-item label="SEO 关键词">
|
||||
<n-input v-model:value="form.seoKeywords" placeholder="逗号分隔" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<n-card title="发布设置">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分类">
|
||||
<n-select v-model:value="form.category" :options="[{ label: '攻略', value: '攻略' }, { label: '亲子', value: '亲子' }, { label: '游记', value: '游记' }]" />
|
||||
</n-form-item>
|
||||
<n-form-item label="作者">
|
||||
<n-input v-model:value="form.author" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="封面图">
|
||||
<AdminImageUpload v-model="form.coverImage" />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/blogs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NAlert } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({
|
||||
title: '', slug: '', category: '攻略', author: '呼籁旅行',
|
||||
summary: '', content: '', coverImage: '',
|
||||
seoTitle: '', seoDesc: '', seoKeywords: '', published: true,
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/articles/${route.params.id}`)
|
||||
form.value = { ...d, published: !!d.published, coverImage: d.coverImage || '' }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim() || !form.value.slug?.trim()) { formError.value = '标题和 Slug 不能为空'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/articles', { method: 'POST', body: { ...form.value, type: 'blog' } })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/blogs'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/articles/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-layout { display: grid; grid-template-columns: 1fr 300px; gap: 20px; align-items: start; }
|
||||
.form-main { min-width: 0; }
|
||||
.form-sidebar { display: flex; flex-direction: column; gap: 16px; }
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理攻略、亲子、游记等长文 · 对应官网「博客」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/blog" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/blogs/new')">新增博客</n-button>
|
||||
</n-space>
|
||||
</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()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<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-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
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 columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '标题', key: 'title',
|
||||
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/blogs/${row.id}`) }, row.title),
|
||||
},
|
||||
{ title: '分类', key: 'category', width: 70, render: (row) => row.category ? h(NTag, { size: 'small', type: 'info', bordered: false }, () => row.category) : '-' },
|
||||
{ title: '作者', key: 'author', width: 80, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.author || '-') },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.published ? 'success' : 'default', bordered: false, style: 'cursor: pointer', onClick: () => togglePublish(row) }, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{ title: '时间', key: 'createdAt', width: 100, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.createdAt?.slice(0, 10)) },
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/blogs/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try { const d = await adminFetch(`/api/admin/articles?type=blog&page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`); list.value = d.items; total.value = d.total; totalPages.value = d.totalPages }
|
||||
catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try { await adminFetch(`/api/admin/articles/${item.id}`, { method: 'PUT', body: { published: !item.published } }); item.published = !item.published }
|
||||
catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try { await adminFetch(`/api/admin/articles/${item.id}`, { method: 'DELETE' }); message.success('已删除'); loadData() }
|
||||
catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<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">{{ contentLabel }}</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #9ca3af">编辑「{{ contentLabel }}」的数据内容</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button v-if="previewUrl" tag="a" :href="previewUrl" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button secondary @click="resetContent">重置</n-button>
|
||||
<n-button type="primary" :disabled="!!jsonError" :loading="saving" @click="saveContent">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-card v-if="loaded">
|
||||
<n-space style="margin-bottom: 12px" size="small">
|
||||
<n-button size="small" @click="formatJSON">格式化</n-button>
|
||||
<n-button size="small" @click="collapseJSON">压缩</n-button>
|
||||
<span style="margin-left: auto; font-size: 12px; color: #9ca3af">此配置结构较复杂,使用 JSON 编辑器</span>
|
||||
</n-space>
|
||||
|
||||
<n-input
|
||||
v-model:value="jsonContent"
|
||||
type="textarea"
|
||||
:rows="30"
|
||||
font-family="monospace"
|
||||
:status="jsonError ? 'error' : undefined"
|
||||
@input="validateJSON"
|
||||
/>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-top: 12px">
|
||||
<n-text v-if="jsonError" type="error" style="font-size: 13px">{{ jsonError }}</n-text>
|
||||
<n-text v-else type="success" style="font-size: 13px">JSON 格式正确</n-text>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NSpin, NText } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const key = computed(() => route.params.key)
|
||||
const contentLabel = ref('')
|
||||
const jsonContent = ref('')
|
||||
const originalContent = ref('')
|
||||
const jsonError = ref('')
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const previewMap = {
|
||||
products: '/products', 'autumn-products': '/products/autumn', 'winter-products': '/products/winter',
|
||||
courses: '/summer-camp', 'winter-camp': '/winter-camp', destinations: '/destinations',
|
||||
'destinations-detail': '/destinations', calendar: '/calendar', guides: '/guides',
|
||||
'xiaohongshu-wall': '/xiaohongshu', youji: '/youji', about: '/about',
|
||||
qualifications: '/qualifications', partners: '/partners', contact: '/contact',
|
||||
pricing: '/pricing', brand: '/', seo: '/', navigation: '/',
|
||||
}
|
||||
const previewUrl = computed(() => previewMap[key.value] || '')
|
||||
|
||||
async function loadData() {
|
||||
loaded.value = false
|
||||
const data = await adminFetch(`/api/admin/site-content?key=${key.value}`)
|
||||
contentLabel.value = data.label
|
||||
jsonContent.value = JSON.stringify(data.data, null, 2)
|
||||
originalContent.value = jsonContent.value
|
||||
jsonError.value = ''
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function validateJSON() {
|
||||
try { JSON.parse(jsonContent.value); jsonError.value = '' }
|
||||
catch (e) { jsonError.value = e.message }
|
||||
}
|
||||
|
||||
function formatJSON() {
|
||||
try { jsonContent.value = JSON.stringify(JSON.parse(jsonContent.value), null, 2); jsonError.value = '' }
|
||||
catch (e) { jsonError.value = e.message }
|
||||
}
|
||||
|
||||
function collapseJSON() {
|
||||
try { jsonContent.value = JSON.stringify(JSON.parse(jsonContent.value)); jsonError.value = '' }
|
||||
catch (e) { jsonError.value = e.message }
|
||||
}
|
||||
|
||||
function resetContent() { jsonContent.value = originalContent.value; jsonError.value = '' }
|
||||
|
||||
async function saveContent() {
|
||||
if (jsonError.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: key.value, data: JSON.parse(jsonContent.value) } })
|
||||
originalContent.value = jsonContent.value
|
||||
message.success('保存成功')
|
||||
} catch (e) { message.error('保存失败:' + (e.data?.message || e.message)) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
watch(key, loadData, { immediate: true })
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<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">管理品牌名称、slogan、信任数据等基础信息</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/" 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">
|
||||
<n-card title="基础信息">
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="4">
|
||||
<n-gi><n-form-item label="品牌名"><n-input v-model:value="data.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="全称"><n-input v-model:value="data.fullName" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="域名"><n-input v-model:value="data.domain" /></n-form-item></n-gi>
|
||||
<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="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-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="信任数据">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.trustStats.push({ value: '', unit: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(s, i) in data.trustStats" :key="i" align="center">
|
||||
<n-input v-model:value="s.value" placeholder="数值" style="width: 80px" />
|
||||
<n-input v-model:value="s.unit" placeholder="单位" style="width: 60px" />
|
||||
<n-input v-model:value="s.label" placeholder="标签" style="width: 160px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.trustStats.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="差异化优势">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.differentiators.push({ title: '', description: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(d, i) in data.differentiators" :key="i" align="center">
|
||||
<n-input v-model:value="d.title" placeholder="标题" style="width: 150px" />
|
||||
<n-input v-model:value="d.description" placeholder="描述" style="width: 400px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.differentiators.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ slogan: {}, trustStats: [], differentiators: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=brand')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'brand', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="flex-start" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 22px; font-weight: 600; color: #111827; margin-bottom: 4px;">旅行日历</div>
|
||||
<div style="font-size: 13px; color: #9ca3af;">管理12个月份的出行建议,对应官网「旅行日历」页面</div>
|
||||
</div>
|
||||
<n-space>
|
||||
<a href="/calendar" target="_blank" style="text-decoration: none;">
|
||||
<n-button>查看前台 ↗</n-button>
|
||||
</a>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin v-if="!loaded" style="display:block; padding: 60px; text-align: center;" />
|
||||
<template v-else>
|
||||
<n-card title="页面概述" style="margin-bottom: 20px;">
|
||||
<n-form-item label="页面标题">
|
||||
<n-input v-model:value="data.meta.pageTitle" />
|
||||
</n-form-item>
|
||||
<n-form-item label="最佳时间">
|
||||
<n-input v-model:value="data.meta.bestTime" />
|
||||
</n-form-item>
|
||||
<n-form-item label="页面介绍">
|
||||
<n-input v-model:value="data.meta.pageIntro" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" responsive="screen" :collapsed-rows="999">
|
||||
<n-gi v-for="m in data.months" :key="m.month">
|
||||
<n-card
|
||||
:style="m.isPeakSeason ? 'border: 2px solid #3a7d44;' : ''"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center">
|
||||
<span style="font-size: 15px; font-weight: 600;">{{ m.month }}月 · {{ m.name }}</span>
|
||||
<n-tag size="small">{{ m.season }}</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-form-item label="温度范围" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tempRange" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="天气特征" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.weatherFeature" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="推荐指数 (1-5)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input-number v-model:value="m.ratingStars" :min="1" :max="5" size="small" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Tagline" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.tagline" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="穿衣建议" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input v-model:value="m.clothingTips" type="textarea" :rows="2" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="亮点(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
size="small"
|
||||
:value="(m.highlights||[]).join('\n')"
|
||||
@input="m.highlights = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space align="center" style="margin-top: 4px;">
|
||||
<n-tag
|
||||
:type="m.isPeakSeason ? 'success' : 'default'"
|
||||
checkable
|
||||
:checked="m.isPeakSeason"
|
||||
@update:checked="m.isPeakSeason = $event"
|
||||
size="small"
|
||||
>
|
||||
旺季
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NFormItem, NSpin, NGrid, NGi, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ meta: {}, months: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() { const res = await adminFetch('/api/admin/site-content?key=calendar'); data.value = res.data; loaded.value = true }
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'calendar', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch { message.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<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">管理官网「联系我们」页面的联系渠道</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/contact" 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">
|
||||
<!-- 联系渠道 -->
|
||||
<n-card title="联系渠道">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addChannel">添加渠道</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="12">
|
||||
<div
|
||||
v-for="(ch, idx) in data.channels"
|
||||
:key="idx"
|
||||
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 14px"
|
||||
>
|
||||
<n-space justify="space-between" align="flex-start">
|
||||
<n-form label-placement="top" style="flex: 1">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="4">
|
||||
<n-gi>
|
||||
<n-form-item label="类型">
|
||||
<n-select
|
||||
v-model:value="ch.type"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标签">
|
||||
<n-input v-model:value="ch.label" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="值">
|
||||
<n-input v-model:value="ch.value" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="ch.description" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label=" ">
|
||||
<n-space>
|
||||
<n-checkbox v-model:checked="ch.primary">主要</n-checkbox>
|
||||
<n-checkbox v-model:checked="ch.hidden">隐藏</n-checkbox>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi v-if="ch.type === 'wechat' || ch.type === 'miniprogram'" :span="2">
|
||||
<n-form-item label="二维码图片">
|
||||
<AdminImageUpload v-model="ch.qrImage" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
<n-button size="tiny" type="error" quaternary @click="data.channels.splice(idx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<n-card title="安全提示文案">
|
||||
<n-input v-model:value="data.securityNotice" type="textarea" :rows="3" />
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NSelect, NCheckbox } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ channels: [], securityNotice: '' })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: '小程序', value: 'miniprogram' },
|
||||
{ label: '电话', value: 'phone' },
|
||||
{ label: '地址', value: 'address' },
|
||||
{ label: '邮箱', value: 'email' },
|
||||
{ label: '投诉', value: 'complaint' },
|
||||
{ label: '招聘', value: 'hr' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=contact')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addChannel() {
|
||||
data.value.channels.push({ type: 'phone', label: '', value: '', description: '', primary: false, hidden: false })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'contact', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败:' + (e.data?.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">小蒙马夏令营</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理研学课程体系,对应官网「夏令营」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/summer-camp" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 教育理念 -->
|
||||
<n-card v-if="data.philosophy" style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.phil = !open.phil">
|
||||
<span>教育理念</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.phil ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.phil">
|
||||
<div v-for="(val, key) in data.philosophy" :key="key" style="margin-bottom: 10px;">
|
||||
<n-form-item :label="key">
|
||||
<n-input
|
||||
v-if="typeof val === 'string'"
|
||||
v-model:value="data.philosophy[key]"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 年龄分组 -->
|
||||
<n-card
|
||||
v-for="(ag, i) in (data.ageGroups || [])"
|
||||
:key="'ag' + i"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open['ag' + i] = !open['ag' + i]">
|
||||
<span>{{ ag.emoji || '' }} {{ ag.label }} — {{ ag.headline || '' }}</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open['ag' + i] ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open['ag' + i]">
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10" style="margin-bottom: 12px;">
|
||||
<n-gi>
|
||||
<n-form-item label="ID">
|
||||
<n-input v-model:value="ag.id" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标签">
|
||||
<n-input v-model:value="ag.label" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="ag.headline" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="侧重点">
|
||||
<n-input v-model:value="ag.focus" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="ag.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="亮点(每行一个)">
|
||||
<n-input
|
||||
:value="(ag.highlights || []).join('\n')"
|
||||
@input="ag.highlights = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 课程模块 -->
|
||||
<n-card
|
||||
v-for="(m, i) in (data.modules || [])"
|
||||
:key="'m' + i"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open['m' + i] = !open['m' + i]">
|
||||
<span>{{ m.icon || '' }} {{ m.name }}</span>
|
||||
<n-space>
|
||||
<n-tag size="small" type="success">{{ m.ageRange }}</n-tag>
|
||||
<n-tag size="small" type="info">{{ m.duration }}</n-tag>
|
||||
<span style="color: #999; font-size: 12px;">{{ open['m' + i] ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open['m' + i]">
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10" style="margin-bottom: 12px;">
|
||||
<n-gi>
|
||||
<n-form-item label="ID">
|
||||
<n-input v-model:value="m.id" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="m.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="年龄段">
|
||||
<n-input v-model:value="m.ageRange" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="时长">
|
||||
<n-input v-model:value="m.duration" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="简介">
|
||||
<n-input v-model:value="m.shortDesc" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="教学目标(每行一个)">
|
||||
<n-input
|
||||
:value="(m.objectives || []).join('\n')"
|
||||
@input="m.objectives = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="活动内容(每行一个)">
|
||||
<n-input
|
||||
:value="(m.activities || []).join('\n')"
|
||||
@input="m.activities = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="教学理念">
|
||||
<n-input v-model:value="m.philosophy" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- FAQ -->
|
||||
<n-card v-if="data.faq?.length" style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.faq = !open.faq">
|
||||
<span>常见问题 ({{ data.faq.length }})</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.faq ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.faq">
|
||||
<div
|
||||
v-for="(q, i) in data.faq"
|
||||
:key="i"
|
||||
style="padding: 10px; background: #fafbfc; border-radius: 6px; margin-bottom: 8px;"
|
||||
>
|
||||
<n-form-item label="Q">
|
||||
<n-input v-model:value="q.q" />
|
||||
</n-form-item>
|
||||
<n-form-item label="A">
|
||||
<n-input v-model:value="q.a" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
const open = reactive({})
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=courses')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'courses', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<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">管理定制咨询页的选项和流程</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/customize" 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">
|
||||
<!-- 信任数据 -->
|
||||
<n-card title="信任数据">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.trustStats.push({ value: '', unit: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(s, i) in data.trustStats" :key="i" align="center">
|
||||
<n-input v-model:value="s.value" placeholder="数值" style="width: 80px" />
|
||||
<n-input v-model:value="s.unit" placeholder="单位" style="width: 60px" />
|
||||
<n-input v-model:value="s.label" placeholder="标签" style="width: 160px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.trustStats.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 出行天数 + 预算 两列 -->
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-card title="出行天数选项">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.durations.push({ value: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(d, i) in data.durations" :key="i" align="center">
|
||||
<n-input v-model:value="d.value" placeholder="值" style="width: 80px" />
|
||||
<n-input v-model:value="d.label" placeholder="标签" style="width: 140px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.durations.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card title="预算选项">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.budgets.push({ value: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(b, i) in data.budgets" :key="i" align="center">
|
||||
<n-input v-model:value="b.value" placeholder="值" style="width: 80px" />
|
||||
<n-input v-model:value="b.label" placeholder="标签" style="width: 140px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.budgets.splice(i, 1)">删</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 活动偏好 -->
|
||||
<n-card title="活动偏好选项">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="data.activities.push({ value: '', label: '' })">添加</n-button>
|
||||
</template>
|
||||
<n-space :size="8" style="flex-wrap: wrap">
|
||||
<n-space v-for="(a, i) in data.activities" :key="i" align="center" :size="4" style="background: #f5f5f5; border-radius: 4px; padding: 4px 8px">
|
||||
<n-input v-model:value="a.label" style="width: 100px" size="small" :bordered="false" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.activities.splice(i, 1)">✕</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 定制流程 -->
|
||||
<n-card title="定制流程">
|
||||
<n-space vertical :size="8">
|
||||
<n-space v-for="(p, i) in data.process" :key="i" align="center">
|
||||
<div style="width: 24px; height: 24px; background: #3a7d44; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0">
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
<n-input v-model:value="p.title" placeholder="步骤标题" style="width: 120px" />
|
||||
<n-input v-model:value="p.desc" placeholder="步骤描述" style="width: 360px" />
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<n-card title="联系方式">
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="微信"><n-input v-model:value="data.contact.wechat" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="提示文案"><n-input v-model:value="data.contact.tip" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ trustStats: [], durations: [], budgets: [], activities: [], process: [], contact: {} })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=customize')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'customize', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<n-breadcrumb style="margin-bottom: 16px;">
|
||||
<n-breadcrumb-item>
|
||||
<NuxtLink to="/admin/content/destinations-detail">景点详情</NuxtLink>
|
||||
</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ form.name || '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">{{ form.name || '编辑景点' }}</div>
|
||||
<n-space>
|
||||
<NuxtLink to="/admin/content/destinations-detail" style="text-decoration: none;">
|
||||
<n-button>返回列表</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin v-if="pageLoading" style="display:block; padding: 60px; text-align: center;" />
|
||||
<n-card v-else-if="loadError" style="color: #ef4444; text-align: center; padding: 40px;">{{ loadError }}</n-card>
|
||||
<template v-else>
|
||||
<n-card title="基本信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="ID">
|
||||
<n-input v-model:value="form.id" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="form.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="form.subtitle" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标签">
|
||||
<n-input v-model:value="form.tag" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="form.description" type="textarea" :rows="5" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-card title="亮点与建议" style="margin-bottom: 16px;">
|
||||
<n-form-item label="亮点(每行一个)">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(form.highlights||[]).join('\n')"
|
||||
@input="form.highlights = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="旅行建议(每行一个)">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:value="(form.tips||[]).join('\n')"
|
||||
@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>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="form.bestSeason" title="最佳季节" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="季节">
|
||||
<n-input v-model:value="form.bestSeason.season" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="原因">
|
||||
<n-input v-model:value="form.bestSeason.reason" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="form.seo" title="SEO" style="margin-bottom: 16px;">
|
||||
<n-form-item label="SEO 标题">
|
||||
<n-input v-model:value="form.seo.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="SEO 描述">
|
||||
<n-input v-model:value="form.seo.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.seo.keywords" label="关键词">
|
||||
<n-input v-model:value="form.seo.keywords" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<p v-if="formError" style="color: #ef4444; font-size: 13px; margin: 12px 0 0;">{{ formError }}</p>
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/destinations-detail" style="text-decoration: none;">
|
||||
<n-button>返回列表</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin', name: 'admin-content-destinations-edit' })
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const idx = computed(() => Number(route.params.idx))
|
||||
const form = ref({})
|
||||
const pageLoading = ref(true); const loadError = ref(''); const saving = ref(false); const formError = ref('')
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
|
||||
const data = res.data
|
||||
const arr = Array.isArray(data) ? data : (data.destinations || [])
|
||||
if (idx.value >= arr.length) { loadError.value = '索引超出范围'; return }
|
||||
form.value = JSON.parse(JSON.stringify(arr[idx.value]))
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
|
||||
const data = res.data
|
||||
if (Array.isArray(data)) {
|
||||
data[idx.value] = form.value
|
||||
} else {
|
||||
data.destinations[idx.value] = form.value
|
||||
}
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'destinations-detail', data } })
|
||||
message.success('保存成功')
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<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-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;"
|
||||
>
|
||||
<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 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;">›</span>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</NuxtLink>
|
||||
</n-space>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NSpin } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const items = ref([]); const loaded = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
|
||||
const data = res.data
|
||||
const arr = Array.isArray(data) ? data : (data.destinations || [])
|
||||
items.value = arr.map((d, i) => ({
|
||||
_index: i, name: d.name || '', subtitle: d.subtitle || '', tag: d.tag || '', id: d.id || '',
|
||||
}))
|
||||
} catch {}
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<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">管理「亲子游目的地怎么选」对比页面内容</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<n-card title="页面信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题"><n-input v-model:value="data.title" /></n-form-item>
|
||||
<n-form-item label="副标题"><n-input v-model:value="data.subtitle" /></n-form-item>
|
||||
<n-form-item label="介绍"><n-input v-model:value="data.intro" type="textarea" :rows="3" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 对比目的地 -->
|
||||
<n-card title="对比目的地" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="data.destinations.push({ id: '', name: '', tag: '', highlight: '' })">添加</n-button></template>
|
||||
<div v-for="(dest, i) in data.destinations" :key="i" style="margin-bottom: 12px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px;">
|
||||
<n-space justify="space-between" style="margin-bottom: 8px">
|
||||
<span style="font-weight: 500">{{ dest.name || '目的地 ' + (i+1) }}</span>
|
||||
<n-button size="tiny" type="error" quaternary @click="data.destinations.splice(i, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="ID" :show-feedback="false"><n-input v-model:value="dest.id" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="名称" :show-feedback="false"><n-input v-model:value="dest.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="标签" :show-feedback="false"><n-input v-model:value="dest.tag" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="推荐高亮" :show-feedback="false"><n-select v-model:value="dest.highlight" :options="[{label:'是',value:true},{label:'否',value:false}]" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 对比维度 -->
|
||||
<n-card title="对比维度" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="data.dimensions.push({ label: '', icon: '', values: data.destinations.map(() => '') })">添加维度</n-button></template>
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(dim, di) in data.dimensions" :key="di" :name="di">
|
||||
<template #header><strong>{{ dim.icon }} {{ dim.label || '维度 ' + (di+1) }}</strong></template>
|
||||
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.dimensions.splice(di, 1)">删除</n-button></template>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="维度名称"><n-input v-model:value="dim.label" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="图标"><n-input v-model:value="dim.icon" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<div v-for="(dest, vi) in data.destinations" :key="vi">
|
||||
<n-form-item :label="dest.name || '目的地 ' + (vi+1)">
|
||||
<n-input v-model:value="dim.values[vi]" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
|
||||
<!-- 坦诚说明 -->
|
||||
<n-card title="坦诚说明" style="margin-top: 16px">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题"><n-input v-model:value="data.honestNote.title" /></n-form-item>
|
||||
<n-form-item label="副标题"><n-input v-model:value="data.honestNote.subtitle" /></n-form-item>
|
||||
<n-form-item label="要点列表(每行一项)">
|
||||
<n-input :value="(data.honestNote.items||[]).join('\n')" @update:value="v => data.honestNote.items = v.split('\n').filter(Boolean)" type="textarea" :rows="6" placeholder="每行一个要点" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 结语 -->
|
||||
<n-card title="结语" style="margin-top: 16px">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题"><n-input v-model:value="data.closing.title" /></n-form-item>
|
||||
<n-form-item label="内容"><n-input v-model:value="data.closing.text" type="textarea" :rows="3" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 数据来源 -->
|
||||
<n-card title="数据来源" style="margin-top: 16px">
|
||||
<n-form-item :show-feedback="false">
|
||||
<n-input v-model:value="data.dataSources" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCollapse, NCollapseItem, NSelect } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=destinations')
|
||||
data.value = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
if (!data.value.destinations) data.value.destinations = []
|
||||
if (!data.value.dimensions) data.value.dimensions = []
|
||||
if (!data.value.honestNote || typeof data.value.honestNote !== 'object') data.value.honestNote = { title: '', subtitle: '', items: [] }
|
||||
if (!data.value.closing || typeof data.value.closing !== 'object') data.value.closing = { title: '', text: '' }
|
||||
if (!data.value.dataSources) data.value.dataSources = ''
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'destinations', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch (e) { message.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="flex-start" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 22px; font-weight: 600; color: #111827; margin-bottom: 4px;">出行指南</div>
|
||||
<div style="font-size: 13px; color: #9ca3af;">管理出行准备指南各章节内容,对应官网「出行指南」页面</div>
|
||||
</div>
|
||||
<n-space>
|
||||
<a href="/guides" target="_blank" style="text-decoration: none;">
|
||||
<n-button>查看前台 ↗</n-button>
|
||||
</a>
|
||||
<n-button type="primary" :loading="saving" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin v-if="!loaded" style="display:block; padding: 60px; text-align: center;" />
|
||||
<template v-else>
|
||||
<n-card title="页面介绍" style="margin-bottom: 12px;">
|
||||
<n-input v-model:value="data.pageIntro" type="textarea" :rows="3" />
|
||||
</n-card>
|
||||
|
||||
<n-card
|
||||
v-for="(s, i) in data.sections"
|
||||
:key="i"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none;"
|
||||
@click="open[i] = !open[i]"
|
||||
>
|
||||
<span style="font-size: 15px; color: #333;">{{ s.icon || '' }} {{ s.title }}</span>
|
||||
<n-space align="center">
|
||||
<span style="font-size: 12px; color: #9ca3af;">{{ s.subtitle || '' }}</span>
|
||||
<span style="font-size: 12px; color: #999; transition: transform 0.2s; display: inline-block;" :style="open[i] ? 'transform: rotate(90deg)' : ''">▸</span>
|
||||
</n-space>
|
||||
</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">
|
||||
<n-input v-model:value="s.id" size="small" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="标题" :show-feedback="false">
|
||||
<n-input v-model:value="s.title" size="small" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题" :show-feedback="false">
|
||||
<n-input v-model:value="s.subtitle" size="small" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="图标" :show-feedback="false">
|
||||
<n-input v-model:value="s.icon" size="small" />
|
||||
</n-form-item>
|
||||
</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-form-item>
|
||||
<n-form-item v-if="s.tips" label="Tips(每行一个)" :show-feedback="false" style="margin-bottom: 10px;">
|
||||
<n-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:value="(s.tips||[]).join('\n')"
|
||||
@input="s.tips = $event.split('\n').filter(Boolean)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<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;">
|
||||
<template v-if="typeof item === 'string'">
|
||||
<n-input
|
||||
:value="item"
|
||||
@input="s.items[ii] = $event"
|
||||
size="small"
|
||||
style="flex: 1; min-width: 200px;"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-input v-model:value="item.text" placeholder="文字" size="small" style="flex: 1; min-width: 200px;" />
|
||||
<n-tag
|
||||
:type="item.important ? 'warning' : 'default'"
|
||||
checkable
|
||||
:checked="item.important"
|
||||
@update:checked="item.important = $event"
|
||||
size="small"
|
||||
>
|
||||
重要
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ pageIntro: '', sections: [] })
|
||||
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 }
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'guides', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch { message.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<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">管理网站各处使用的图片资源</p>
|
||||
</div>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="16">
|
||||
<n-card v-for="(group, groupKey) in data" :key="groupKey" :title="groupLabels[groupKey] || groupKey">
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
<n-gi v-for="(val, imgKey) in group" :key="imgKey">
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6b7280; margin-bottom: 6px; font-weight: 500">{{ imgKey }}</div>
|
||||
<AdminImageUpload v-model="data[groupKey][imgKey]" />
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const groupLabels = {
|
||||
logo: 'Logo', hero: '首页横幅', brandStory: '品牌故事',
|
||||
mascot: '吉祥物', team: '团队', xiaohongshu: '小红书', summerCamp: '夏令营',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=images')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'images', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">合作伙伴</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理政府、协会、学术、景区、酒店等合作伙伴信息</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/partners" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<n-card
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open[section.key] = !open[section.key]">
|
||||
<span>{{ section.label }} ({{ (data[section.key] || []).length }})</span>
|
||||
<n-space>
|
||||
<n-button size="small" type="primary" @click.stop="addItem(section.key, section.template)">添加</n-button>
|
||||
<span style="color: #999; font-size: 12px;">{{ open[section.key] ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<div v-show="open[section.key]">
|
||||
<div
|
||||
v-for="(item, i) in (data[section.key] || [])"
|
||||
:key="i"
|
||||
style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap;"
|
||||
>
|
||||
<n-input v-model:value="item.name" placeholder="名称" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.type !== undefined" v-model:value="item.type" placeholder="类型" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.description !== undefined" v-model:value="item.description" placeholder="描述" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.relationship !== undefined" v-model:value="item.relationship" placeholder="关系" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.platform !== undefined" v-model:value="item.platform" placeholder="平台" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.status !== undefined" v-model:value="item.status" placeholder="状态" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.detail !== undefined" v-model:value="item.detail" placeholder="详情" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.outlet !== undefined" v-model:value="item.outlet" placeholder="媒体" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.title !== undefined" v-model:value="item.title" placeholder="标题" style="flex: 2; min-width: 100px;" />
|
||||
<n-input v-if="item.date !== undefined" v-model:value="item.date" placeholder="日期" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.url !== undefined" v-model:value="item.url" placeholder="链接" style="flex: 1; min-width: 80px;" />
|
||||
<n-input v-if="item.summary !== undefined" v-model:value="item.summary" placeholder="摘要" style="flex: 2; min-width: 100px;" />
|
||||
<n-space v-if="item.verified !== undefined" align="center">
|
||||
<n-switch v-model:value="item.verified" size="small" />
|
||||
<span style="font-size: 12px; color: #666; white-space: nowrap;">已认证</span>
|
||||
</n-space>
|
||||
<n-button size="small" type="error" @click="data[section.key].splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<p v-if="!(data[section.key] || []).length" style="color: #ccc; font-size: 13px; text-align: center; padding: 10px;">暂无数据</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NSpin, NSwitch } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
const open = reactive({})
|
||||
|
||||
const sections = [
|
||||
{ key: 'govPartners', label: '政府合作', template: { name: '', type: '', description: '', verified: false } },
|
||||
{ key: 'associations', label: '行业协会', template: { name: '', type: '', relationship: '', verified: false } },
|
||||
{ key: 'academicCoops', label: '学术合作', template: { name: '', type: '', relationship: '', description: '', verified: false } },
|
||||
{ key: 'scenicPartners', label: '景区合作', template: { name: '', description: '', verified: false } },
|
||||
{ key: 'hotelPartners', label: '酒店合作', template: { name: '', description: '', verified: false } },
|
||||
{ key: 'platformEndorsements', label: '平台认证', template: { platform: '', status: '', detail: '', verified: false } },
|
||||
{ key: 'mediaReports', label: '媒体报道', template: { outlet: '', title: '', date: '', url: '', summary: '' } },
|
||||
]
|
||||
|
||||
function addItem(key, template) {
|
||||
if (!data.value[key]) data.value[key] = []
|
||||
data.value[key].push({ ...template })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=partners')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'partners', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">资质荣誉</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理公司资质、荣誉、保障信息,对应官网「资质荣誉」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/qualifications" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 安全保障 -->
|
||||
<n-card title="安全保障" style="margin-bottom: 16px;">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addTo('safetyGuarantees', { title: '', detail: '' })">添加</n-button>
|
||||
</template>
|
||||
<div v-for="(s, i) in data.safetyGuarantees" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
|
||||
<n-input v-model:value="s.title" placeholder="标题" style="width: 160px;" />
|
||||
<n-input v-model:value="s.detail" placeholder="详情" style="flex: 1;" />
|
||||
<n-button size="small" type="error" @click="data.safetyGuarantees.splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button dashed block @click="addTo('safetyGuarantees', { title: '', detail: '' })">+ 添加安全保障</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 资质证照 -->
|
||||
<n-card title="资质证照" style="margin-bottom: 16px;">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addTo('licenses', { holder: '', issuer: '', licenseNo: '', validPeriod: '', note: '' })">添加</n-button>
|
||||
</template>
|
||||
<div v-for="(l, i) in data.licenses" :key="i" style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; margin-bottom: 8px; display: flex; gap: 8px; align-items: flex-start;">
|
||||
<div style="flex: 1; display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
<n-input v-model:value="l.holder" placeholder="持有人" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.issuer" placeholder="颁发机构" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.licenseNo" placeholder="证号" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.validPeriod" placeholder="有效期" style="flex: 1; min-width: 100px;" />
|
||||
<n-input v-model:value="l.note" placeholder="备注" style="flex: 1; min-width: 100px;" />
|
||||
</div>
|
||||
<n-button size="small" type="error" @click="data.licenses.splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button dashed block @click="addTo('licenses', { holder: '', issuer: '', licenseNo: '', validPeriod: '', note: '' })">+ 添加资质证照</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 荣誉奖项 -->
|
||||
<n-card title="荣誉奖项" style="margin-bottom: 16px;">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addTo('awards', { year: '', title: '', detail: '', issuer: '' })">添加</n-button>
|
||||
</template>
|
||||
<div v-for="(a, i) in data.awards" :key="i" style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
|
||||
<n-input v-model:value="a.year" placeholder="年份" style="width: 70px;" />
|
||||
<n-input v-model:value="a.title" placeholder="奖项名称" style="width: 200px;" />
|
||||
<n-input v-model:value="a.issuer" placeholder="颁发方" style="width: 130px;" />
|
||||
<n-input v-model:value="a.detail" placeholder="详情" style="flex: 1;" />
|
||||
<n-button size="small" type="error" @click="data.awards.splice(i, 1)">删</n-button>
|
||||
</div>
|
||||
<n-button dashed block @click="addTo('awards', { year: '', title: '', detail: '', issuer: '' })">+ 添加荣誉奖项</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 保险信息 -->
|
||||
<n-card v-if="data.insurance" title="保险信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="data.insurance.title" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="保险公司">
|
||||
<n-input v-model:value="data.insurance.insurer" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi :span="2">
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="data.insurance.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="保障范围">
|
||||
<n-input v-model:value="data.insurance.policyScope" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="理赔联系">
|
||||
<n-input v-model:value="data.insurance.claimContact" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=qualifications')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addTo(key, template) {
|
||||
if (!data.value[key]) data.value[key] = []
|
||||
data.value[key].push({ ...template })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'qualifications', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div style="max-width: 800px;">
|
||||
<NBreadcrumb style="margin-bottom: 16px;">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/content/selector">产品选择器</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>Q{{ idx + 1 }}: {{ form.text || '编辑' }}</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">编辑问题 Q{{ idx + 1 }}</div>
|
||||
<NSpace :size="8">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回列表</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="pageLoading">
|
||||
<template v-if="!pageLoading">
|
||||
<NCard style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">问题设置</span>
|
||||
</template>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem label="问题文本">
|
||||
<NInput v-model:value="form.text" />
|
||||
</NFormItem>
|
||||
<NFormItem label="提示语">
|
||||
<NInput v-model:value="form.hint" />
|
||||
</NFormItem>
|
||||
<NGrid v-if="form.condition" :cols="2" :x-gap="12">
|
||||
<NGi>
|
||||
<NFormItem label="条件:依赖问题">
|
||||
<NInput v-model:value="form.condition.key" placeholder="如 q2" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NFormItem label="条件:选择值">
|
||||
<NInput v-model:value="form.condition.value" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
</NCard>
|
||||
|
||||
<NCard style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">选项 ({{ form.options?.length || 0 }})</span>
|
||||
<NButton size="small" type="primary" @click="addOption">+ 添加选项</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
<NSpace vertical :size="10">
|
||||
<div
|
||||
v-for="(opt, i) in (form.options || [])"
|
||||
:key="i"
|
||||
style="display: flex; gap: 12px; align-items: flex-start; padding: 14px; background: #f9fafb; border: 1px solid #f3f4f6; border-radius: 8px;"
|
||||
>
|
||||
<div style="width: 28px; height: 28px; background: #e5e7eb; color: #374151; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0; margin-top: 4px;">
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<NGrid :cols="2" :x-gap="8">
|
||||
<NGi>
|
||||
<NFormItem label="标签" label-style="font-size: 13px;" style="margin-bottom: 8px;">
|
||||
<NInput v-model:value="opt.label" size="small" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NFormItem label="值" label-style="font-size: 13px;" style="margin-bottom: 8px;">
|
||||
<NInput v-model:value="opt.value" size="small" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
<NGi :span="2">
|
||||
<NFormItem label="描述" label-style="font-size: 13px;" style="margin-bottom: 0;">
|
||||
<NInput v-model:value="opt.desc" size="small" />
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
<NButton size="small" type="error" ghost @click="form.options.splice(i, 1)" style="flex-shrink: 0; margin-top: 4px;">删除</NButton>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<div v-if="formError" style="color: #ef4444; font-size: 13px; margin-bottom: 12px;">{{ formError }}</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回列表</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin', name: 'admin-content-selector-edit' })
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const idx = computed(() => Number(route.params.idx))
|
||||
const form = ref({})
|
||||
const pageLoading = ref(true)
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
const q = res.data.questions?.[idx.value]
|
||||
if (!q) { formError.value = '不存在'; return }
|
||||
form.value = JSON.parse(JSON.stringify(q))
|
||||
} catch (e) {
|
||||
formError.value = '加载失败'
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
if (!form.value.options) form.value.options = []
|
||||
form.value.options.push({ label: '', value: '', desc: '' })
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
res.data.questions[idx.value] = form.value
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'selector', data: res.data } })
|
||||
message.success('保存成功')
|
||||
} catch (e) {
|
||||
formError.value = e.data?.message || '保存失败'
|
||||
message.error(formError.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<NSpace justify="space-between" align="start" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<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="/selector" target="_blank">
|
||||
<NButton>查看前台</NButton>
|
||||
</a>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 问题列表 -->
|
||||
<div style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 10px;">
|
||||
问题列表 ({{ data.questions?.length || 0 }})
|
||||
</div>
|
||||
<NSpace vertical :size="8" style="margin-bottom: 24px;">
|
||||
<NuxtLink
|
||||
v-for="(q, i) in data.questions"
|
||||
:key="i"
|
||||
:to="`/admin/content/selector/${i}`"
|
||||
style="text-decoration: none; color: inherit; display: block;"
|
||||
>
|
||||
<NCard
|
||||
size="small"
|
||||
hoverable
|
||||
style="cursor: pointer;"
|
||||
:content-style="{ padding: '14px 20px' }"
|
||||
>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<NSpace align="center" :size="14">
|
||||
<div style="width: 36px; height: 36px; background: #3a7d44; color: #fff; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0;">
|
||||
Q{{ i + 1 }}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 14px; font-weight: 600; color: #111827; margin-bottom: 2px;">{{ q.text }}</div>
|
||||
<div style="font-size: 12px; color: #9ca3af;">{{ q.hint }}</div>
|
||||
</div>
|
||||
</NSpace>
|
||||
<NSpace align="center" :size="8">
|
||||
<NTag type="success" size="small">{{ q.options?.length || 0 }} 个选项</NTag>
|
||||
<NTag v-if="q.condition" type="warning" size="small">条件显示</NTag>
|
||||
<span style="font-size: 20px; color: #d1d5db;">›</span>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NuxtLink>
|
||||
</NSpace>
|
||||
|
||||
<!-- 评分矩阵 -->
|
||||
<div style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 10px;">评分矩阵</div>
|
||||
<NCard style="margin-bottom: 24px;" :content-style="{ padding: '20px' }">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 12px;">每个问题的每个选项对应各产品的评分权重,用于计算最终推荐排名。</div>
|
||||
<NuxtLink to="/admin/content/selector/scoring" style="color: #3a7d44; font-size: 14px; font-weight: 500; text-decoration: none;">
|
||||
编辑评分矩阵 ›
|
||||
</NuxtLink>
|
||||
</NCard>
|
||||
|
||||
<!-- 推荐理由 -->
|
||||
<div style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 10px;">推荐理由</div>
|
||||
<NCard :content-style="{ padding: '20px' }">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 12px;">每个产品在不同匹配情况下的推荐文案。</div>
|
||||
<NuxtLink to="/admin/content/selector/reasons" style="color: #3a7d44; font-size: 14px; font-weight: 500; text-decoration: none;">
|
||||
编辑推荐理由 ›
|
||||
</NuxtLink>
|
||||
</NCard>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NSpin } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const data = ref({ questions: [] })
|
||||
const loaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await adminFetch('/api/admin/site-content?key=selector')
|
||||
data.value = r.data
|
||||
} catch {}
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div style="max-width: 900px;">
|
||||
<NBreadcrumb style="margin-bottom: 16px;">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/content/selector">产品选择器</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>推荐理由</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">推荐理由</div>
|
||||
<NSpace :size="8">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 20px;">
|
||||
每个产品在不同匹配情况下展示的推荐文案。
|
||||
</div>
|
||||
|
||||
<NCard
|
||||
v-for="(reasons, pid) in matchReasons"
|
||||
:key="pid"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<template #header>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">{{ pid }}</span>
|
||||
</template>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem
|
||||
v-for="(text, rKey) in reasons"
|
||||
:key="rKey"
|
||||
:label="rKey"
|
||||
:label-style="{ fontFamily: 'monospace', fontSize: '12px', color: '#9ca3af' }"
|
||||
>
|
||||
<NInput v-model:value="matchReasons[pid][rKey]" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const message = useMessage()
|
||||
const { adminFetch } = useAdmin()
|
||||
const fullData = ref({})
|
||||
const matchReasons = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
fullData.value = res.data
|
||||
matchReasons.value = JSON.parse(JSON.stringify(res.data.matchReasons || {}))
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
fullData.value.matchReasons = matchReasons.value
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'selector', data: fullData.value } })
|
||||
message.success('保存成功')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px;">
|
||||
<NBreadcrumb style="margin-bottom: 16px;">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/content/selector">产品选择器</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>评分矩阵</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #111827;">评分矩阵</div>
|
||||
<NSpace :size="8">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<div style="font-size: 13px; color: #6b7280; margin-bottom: 20px;">
|
||||
每个问题的每个选项对应各产品的评分(0-10),分数越高越推荐该产品。
|
||||
</div>
|
||||
|
||||
<NCard
|
||||
v-for="(qScores, qId) in scoring"
|
||||
:key="qId"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<template #header>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #374151;">
|
||||
{{ qId }} — {{ questionTexts[qId] || '' }}
|
||||
</span>
|
||||
</template>
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 6px 8px; border: 1px solid #e5e7eb; background: #f9fafb; font-weight: 600; color: #374151; font-size: 11px; white-space: nowrap; text-align: center;">选项</th>
|
||||
<th
|
||||
v-for="pid in productIds"
|
||||
:key="pid"
|
||||
style="padding: 6px 8px; border: 1px solid #e5e7eb; background: #f9fafb; font-weight: 600; color: #374151; font-size: 11px; white-space: nowrap; text-align: center;"
|
||||
>{{ pid }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(products, optValue) in qScores" :key="optValue">
|
||||
<td style="padding: 6px 8px; border: 1px solid #e5e7eb; text-align: left; font-weight: 500; color: #111827; white-space: nowrap; min-width: 100px;">{{ optValue }}</td>
|
||||
<td v-for="pid in productIds" :key="pid" style="padding: 6px 8px; border: 1px solid #e5e7eb; text-align: center;">
|
||||
<NInputNumber
|
||||
:value="products[pid] || 0"
|
||||
:min="0"
|
||||
:max="10"
|
||||
size="small"
|
||||
style="width: 64px;"
|
||||
:show-button="false"
|
||||
@update:value="val => setScore(qId, optValue, pid, Number(val || 0))"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<div class="save-bar">
|
||||
<NuxtLink to="/admin/content/selector">
|
||||
<NButton>返回</NButton>
|
||||
</NuxtLink>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInputNumber, NSpin, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const message = useMessage()
|
||||
const { adminFetch } = useAdmin()
|
||||
const fullData = ref({})
|
||||
const scoring = ref({})
|
||||
const questionTexts = ref({})
|
||||
const productIds = ref([])
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=selector')
|
||||
fullData.value = res.data
|
||||
scoring.value = JSON.parse(JSON.stringify(res.data.scoring || {}))
|
||||
// 收集所有产品 ID
|
||||
const pids = new Set()
|
||||
for (const q of Object.values(scoring.value)) {
|
||||
for (const opts of Object.values(q)) {
|
||||
for (const pid of Object.keys(opts)) pids.add(pid)
|
||||
}
|
||||
}
|
||||
productIds.value = [...pids].sort()
|
||||
// 问题文本映射
|
||||
for (const q of (res.data.questions || [])) {
|
||||
questionTexts.value[q.id] = q.text
|
||||
}
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function setScore(qId, optValue, pid, val) {
|
||||
if (!scoring.value[qId][optValue]) scoring.value[qId][optValue] = {}
|
||||
if (val > 0) scoring.value[qId][optValue][pid] = val
|
||||
else delete scoring.value[qId][optValue][pid]
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
fullData.value.scoring = scoring.value
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'selector', data: fullData.value } })
|
||||
message.success('保存成功')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<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">SEO 配置</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #9ca3af">管理各页面的 SEO 标题、描述、关键词</p>
|
||||
</div>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<n-space vertical :size="16">
|
||||
<n-card v-for="(seo, pageKey) in data.pages" :key="pageKey">
|
||||
<template #header>
|
||||
<n-space align="center" :size="8">
|
||||
<span>{{ pageLabels[pageKey] || pageKey }}</span>
|
||||
<n-tag size="small" :bordered="false" style="font-size: 11px; color: #aaa; background: #f0f0f0">{{ pageKey }}</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="Title">
|
||||
<n-input v-model:value="seo.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="H1">
|
||||
<n-input v-model:value="seo.h1" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Description">
|
||||
<n-input v-model:value="seo.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Keywords">
|
||||
<n-input v-model:value="seo.keywords" placeholder="逗号分隔" />
|
||||
</n-form-item>
|
||||
<n-form-item label="OG Image">
|
||||
<AdminImageUpload v-model="seo.ogImage" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</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()
|
||||
const data = ref({ pages: {} })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const pageLabels = {
|
||||
home: '首页', products: '产品页', 'summer-camp': '夏令营', 'winter-camp': '冬令营',
|
||||
destinations: '目的地', guides: '出行指南', calendar: '旅行日历', faq: '常见问答',
|
||||
reviews: '客户评价', gallery: '旅拍', about: '关于我们', contact: '联系我们',
|
||||
news: '最新动态', qualifications: '资质荣誉', blog: '博客', pricing: '价格',
|
||||
customize: '定制', selector: '产品选择器', stories: '客户故事', xiaohongshu: '小红书',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=seo')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'seo', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">小蒙马冬令营</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理冬令营产品信息,对应官网「冬令营」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/winter-camp" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 基本信息 -->
|
||||
<n-card style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.base = !open.base">
|
||||
<span>基本信息</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.base ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.base">
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10">
|
||||
<n-gi>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="data.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="定位">
|
||||
<n-input v-model:value="data.positioning" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="天数">
|
||||
<n-input-number v-model:value="data.days" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="晚数">
|
||||
<n-input-number v-model:value="data.nights" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="最大家庭数">
|
||||
<n-input-number v-model:value="data.maxFamilies" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="年龄段">
|
||||
<n-input v-model:value="data.ageRange" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="定金">
|
||||
<n-input v-model:value="data.deposit" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="路线">
|
||||
<n-input v-model:value="data.route" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 行程 -->
|
||||
<n-card style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.itin = !open.itin">
|
||||
<span>每日行程 ({{ data.itinerary?.length || 0 }}天)</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.itin ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.itin">
|
||||
<div
|
||||
v-for="(day, i) in (data.itinerary || [])"
|
||||
:key="i"
|
||||
style="display: flex; gap: 12px; margin-bottom: 10px; padding: 10px; background: #fafbfc; border-radius: 6px;"
|
||||
>
|
||||
<div style="width: 36px; height: 36px; background: #3a7d44; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0;">
|
||||
D{{ day.day || i + 1 }}
|
||||
</div>
|
||||
<div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">
|
||||
<n-form-item label="当日标题" :show-feedback="false">
|
||||
<n-input v-model:value="day.title" placeholder="当日标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="行程概要" :show-feedback="false">
|
||||
<n-input v-model:value="day.summary" placeholder="行程概要" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="day.highlights" label="亮点(每行一个)" :show-feedback="false">
|
||||
<n-input
|
||||
:value="(day.highlights || []).join('\n')"
|
||||
@input="day.highlights = $event.split('\n').filter(Boolean)"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="day.hotel !== undefined" label="住宿" :show-feedback="false">
|
||||
<n-input v-model:value="day.hotel" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 住宿安排 -->
|
||||
<n-card style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.hotel = !open.hotel">
|
||||
<span>住宿安排 ({{ data.hotels?.length || 0 }})</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.hotel ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.hotel">
|
||||
<div
|
||||
v-for="(h, i) in (data.hotels || [])"
|
||||
:key="i"
|
||||
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; margin-bottom: 10px;"
|
||||
>
|
||||
<n-grid :cols="2" :x-gap="10" :y-gap="10" style="margin-bottom: 10px;">
|
||||
<n-gi>
|
||||
<n-form-item label="酒店名">
|
||||
<n-input v-model:value="h.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="星级">
|
||||
<n-input v-model:value="h.star" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="住几晚">
|
||||
<n-input-number v-model:value="h.nights" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="h.description" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- FAQ -->
|
||||
<n-card v-if="data.faq?.length" style="margin-bottom: 12px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center" style="cursor: pointer;" @click="open.faq = !open.faq">
|
||||
<span>常见问题 ({{ data.faq.length }})</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ open.faq ? '▼' : '▶' }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-show="open.faq">
|
||||
<div
|
||||
v-for="(q, i) in data.faq"
|
||||
:key="i"
|
||||
style="padding: 10px; background: #fafbfc; border-radius: 6px; margin-bottom: 8px;"
|
||||
>
|
||||
<n-form-item label="Q">
|
||||
<n-input v-model:value="q.q" />
|
||||
</n-form-item>
|
||||
<n-form-item label="A">
|
||||
<n-input v-model:value="q.a" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({})
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
const open = reactive({ base: true })
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=winter-camp')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'winter-camp', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">小红书游记</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理小红书内容墙,对应官网「小红书游记」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/xiaohongshu" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 页面基本信息 -->
|
||||
<n-card title="页面信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="data.title" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="data.subtitle" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="账号名称">
|
||||
<n-input v-model:value="data.account.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="账号链接">
|
||||
<n-input v-model:value="data.account.url" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="粉丝数">
|
||||
<n-input v-model:value="data.account.followers" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="获赞数">
|
||||
<n-input v-model:value="data.account.likes" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- 笔记列表 -->
|
||||
<n-card style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center">
|
||||
<span>笔记列表 ({{ data.notes?.length || 0 }})</span>
|
||||
<n-button size="small" type="primary" @click="addNote">添加笔记</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-grid :cols="3" :x-gap="14" :y-gap="14" responsive="screen" cols="1 m:2 l:3">
|
||||
<n-gi v-for="(n, i) in data.notes" :key="i">
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<AdminImageUpload v-model="n.cover" />
|
||||
</div>
|
||||
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.title" placeholder="标题" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="作者" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.author" placeholder="作者" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="小红书链接" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.url" placeholder="小红书链接" size="small" />
|
||||
</n-form-item>
|
||||
<n-space style="margin-bottom: 6px;">
|
||||
<n-form-item label="点赞" :show-feedback="false">
|
||||
<n-input-number v-model:value="n.likes" placeholder="点赞" size="small" style="width: 90px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="版式" :show-feedback="false">
|
||||
<n-select v-model:value="n.ratio" size="small" style="width: 90px;" :options="[{label:'竖版',value:'vertical'},{label:'横版',value:'horizontal'}]" />
|
||||
</n-form-item>
|
||||
</n-space>
|
||||
<n-form-item label="摘要" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.summary" placeholder="摘要" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标签(逗号分隔)" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input
|
||||
:value="(n.tags || []).join(', ')"
|
||||
@input="n.tags = $event.split(',').map(t => t.trim()).filter(Boolean)"
|
||||
placeholder="标签(逗号分隔)"
|
||||
size="small"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button size="small" type="error" @click="data.notes.splice(i, 1)">删除</n-button>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-button dashed block style="margin-top: 14px;" @click="addNote">+ 添加笔记</n-button>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSpin, NGrid, NGi, NSelect } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ title: '', subtitle: '', account: {}, notes: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=xiaohongshu-wall')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
data.value.notes.push({ id: `xhs-${Date.now()}`, title: '', cover: '', author: '', likes: 0, url: '', tags: [], ratio: 'vertical', summary: '' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'xiaohongshu-wall', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #1a1a2e;">客户游记</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #888;">管理客户在各平台发布的游记,对应官网「客户游记」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/youji" target="_blank">查看前台 ↗</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<template v-if="loaded">
|
||||
<!-- 页面信息 -->
|
||||
<n-card title="页面信息" style="margin-bottom: 16px;">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="data.title" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="data.subtitle" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- 游记列表 -->
|
||||
<n-card style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<n-space justify="space-between" align="center">
|
||||
<span>游记列表 ({{ data.notes?.length || 0 }})</span>
|
||||
<n-button size="small" type="primary" @click="addNote">添加游记</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-grid :cols="3" :x-gap="14" :y-gap="14" responsive="screen" cols="1 m:2 l:3">
|
||||
<n-gi v-for="(n, i) in data.notes" :key="i">
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<AdminImageUpload v-model="n.cover" />
|
||||
</div>
|
||||
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.title" placeholder="标题" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="作者" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.author" placeholder="作者" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="平台" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-select
|
||||
v-model:value="n.platform"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: '美篇', value: '美篇' },
|
||||
{ label: '微信公众号', value: '微信公众号' },
|
||||
{ label: '抖音', value: '抖音' },
|
||||
{ label: '微博', value: '微博' },
|
||||
{ label: '小红书', value: '小红书' },
|
||||
]"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="原文链接" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.url" placeholder="原文链接" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="摘要" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="n.summary" placeholder="摘要" size="small" />
|
||||
</n-form-item>
|
||||
<n-form-item label="版式" :show-feedback="false" style="margin-bottom: 6px;">
|
||||
<n-select
|
||||
v-model:value="n.ratio"
|
||||
size="small"
|
||||
:options="[{ label: '竖版', value: 'vertical' }, { label: '横版', value: 'horizontal' }]"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button size="small" type="error" @click="data.notes.splice(i, 1)">删除</n-button>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-button dashed block style="margin-top: 14px;" @click="addNote">+ 添加游记</n-button>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NSelect } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const data = ref({ title: '', subtitle: '', notes: [] })
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await adminFetch('/api/admin/site-content?key=youji')
|
||||
data.value = res.data
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
data.value.notes.push({ id: `yj-${Date.now()}`, title: '', cover: '', author: '', platform: '美篇', url: '', tags: [], ratio: 'horizontal', summary: '' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', { method: 'PUT', body: { key: 'youji', data: data.value } })
|
||||
message.success('已保存')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/faqs')">常见问答</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增问答' : '编辑问答' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/faqs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<n-card style="max-width: 700px">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="问题" required>
|
||||
<n-input v-model:value="form.question" placeholder="输入常见问题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="答案" required>
|
||||
<n-input v-model:value="form.answer" type="textarea" :rows="8" placeholder="输入答案内容" />
|
||||
</n-form-item>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.sortOrder" :min="0" placeholder="数字越小越靠前" style="width: 200px" />
|
||||
</n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" style="width: 200px" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px; max-width: 700px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/faqs')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NAlert } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({ question: '', answer: '', sortOrder: 0, published: true })
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/faqs/${route.params.id}`)
|
||||
form.value = { ...data, published: !!data.published }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.question?.trim()) { formError.value = '问题不能为空'; return }
|
||||
if (!form.value.answer?.trim()) { formError.value = '答案不能为空'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/faqs', { method: 'POST', body: form.value })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/faqs'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/faqs/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; max-width: 700px; }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理FAQ条目 · 对应官网「常见问答」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/faq" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/faqs/new')">新增问答</n-button>
|
||||
</n-space>
|
||||
</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()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<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-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
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 columns = [
|
||||
{ title: '排序', key: 'sortOrder', width: 60, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.sortOrder) },
|
||||
{
|
||||
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),
|
||||
},
|
||||
{ title: '答案摘要', key: 'answer', render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.answer?.slice(0, 60)) },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.published ? 'success' : 'default', bordered: false, style: 'cursor: pointer', onClick: () => togglePublish(row) }, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/faqs/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.question}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try { const d = await adminFetch(`/api/admin/faqs?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`); list.value = d.items; total.value = d.total; totalPages.value = d.totalPages }
|
||||
catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try { await adminFetch(`/api/admin/faqs/${item.id}`, { method: 'PUT', body: { published: !item.published } }); item.published = !item.published }
|
||||
catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try { await adminFetch(`/api/admin/faqs/${item.id}`, { method: 'DELETE' }); message.success('已删除'); loadData() }
|
||||
catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/gallery')">呼籁旅拍</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">{{ isNew ? '新增照片' : '编辑照片' }}</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/gallery')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444">{{ loadError }}</n-card>
|
||||
<n-spin v-else-if="pageLoading" style="display: flex; justify-content: center; padding: 60px" />
|
||||
|
||||
<template v-else>
|
||||
<div class="form-layout">
|
||||
<div class="form-main">
|
||||
<n-card title="照片信息">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入照片标题" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-form-item label="分类">
|
||||
<n-select v-model:value="form.category" :options="categoryOptions" placeholder="选择分类" clearable />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="地点">
|
||||
<n-input v-model:value="form.location" placeholder="拍摄地点" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="form.description" type="textarea" :rows="4" placeholder="照片描述(可选)" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-form-item label="方向">
|
||||
<n-select v-model:value="form.orientation" :options="[{ label: '横屏', value: 'landscape' }, { label: '竖屏', value: 'portrait' }]" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.sortOrder" :min="0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<n-card title="发布设置">
|
||||
<n-form-item label="状态">
|
||||
<n-select v-model:value="form.published" :options="[{ label: '已发布', value: true }, { label: '草稿', value: false }]" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
<n-card title="照片">
|
||||
<AdminImageUpload v-model="form.image" />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-alert v-if="formError" type="error" style="margin-top: 12px">{{ formError }}</n-alert>
|
||||
|
||||
<div class="save-bar">
|
||||
<n-button secondary @click="navigateTo('/admin/gallery')">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSelect, NBreadcrumb, NBreadcrumbItem, NSpin, NAlert, NGrid, NGi } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: '草原', value: '草原' }, { label: '森林', value: '森林' },
|
||||
{ label: '湖泊', value: '湖泊' }, { label: '冬季', value: '冬季' },
|
||||
{ label: '人文', value: '人文' }, { label: '其他', value: '其他' },
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
title: '', category: '', location: '', description: '',
|
||||
image: '', orientation: 'landscape', published: true, sortOrder: 0,
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/gallery/${route.params.id}`)
|
||||
form.value = { ...data, published: !!data.published, image: data.image || '' }
|
||||
} catch (e) { loadError.value = e.data?.message || '加载失败' }
|
||||
finally { pageLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim()) { formError.value = '标题不能为空'; return }
|
||||
if (!form.value.image?.trim()) { formError.value = '请上传照片'; return }
|
||||
saving.value = true; formError.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/gallery', { method: 'POST', body: form.value })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/gallery'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/gallery/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) { formError.value = e.data?.message || '保存失败' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-layout { display: grid; grid-template-columns: 1fr 300px; gap: 20px; align-items: start; }
|
||||
.form-main { min-width: 0; }
|
||||
.form-sidebar { display: flex; flex-direction: column; gap: 16px; }
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理旅拍照片 · 对应官网「呼籁旅拍」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/gallery" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/gallery/new')">新增照片</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索标题、分类..." clearable style="width: 260px" @keyup.enter="loadData">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="(row) => row.id"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
striped
|
||||
/>
|
||||
|
||||
<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-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NImage, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
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 columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '缩略',
|
||||
key: 'image',
|
||||
width: 60,
|
||||
render: (row) => row.image
|
||||
? h(NImage, { src: row.image, width: 48, height: 36, objectFit: 'cover', style: 'border-radius: 4px', lazy: true })
|
||||
: h('div', { style: 'width:48px;height:36px;border-radius:4px;background:#f3f4f6' }),
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
render: (row) => h('a', {
|
||||
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
|
||||
onClick: () => navigateTo(`/admin/gallery/${row.id}`),
|
||||
}, row.title),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
key: 'category',
|
||||
width: 80,
|
||||
render: (row) => row.category ? h(NTag, { size: 'small', type: 'info', bordered: false }, () => row.category) : '-',
|
||||
},
|
||||
{ title: '地点', key: 'location', width: 100, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.location || '-') },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'published',
|
||||
width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small',
|
||||
type: row.published ? 'success' : 'default',
|
||||
bordered: false,
|
||||
style: 'cursor: pointer',
|
||||
onClick: () => togglePublish(row),
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/gallery/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/gallery?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
|
||||
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
|
||||
} catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/gallery/${item.id}`, { method: 'PUT', body: { published: !item.published } })
|
||||
item.published = !item.published
|
||||
message.success(item.published ? '已发布' : '已下架')
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/gallery/${item.id}`, { method: 'DELETE' })
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
} catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; color: #111827; font-weight: 600">数据概览</h1>
|
||||
|
||||
<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="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>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NCard, NGrid, NGi, NStatistic, NSpace, NSpin } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const stats = ref(null)
|
||||
|
||||
const statCards = computed(() => {
|
||||
if (!stats.value) return []
|
||||
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: '可编辑配置项' },
|
||||
]
|
||||
})
|
||||
|
||||
const quickLinks = [
|
||||
{ title: '产品线管理', desc: '管理所有产品线', to: '/admin/products' },
|
||||
{ title: '导航菜单', desc: '编辑顶部和底部导航', to: '/admin/navigation' },
|
||||
{ title: '查看表单', desc: '客户咨询和留言', to: '/admin/submissions' },
|
||||
{ title: 'SEO 配置', desc: '页面标题和描述', to: '/admin/content/seo' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try { stats.value = await adminFetch('/api/admin/stats') } catch (e) { console.error('加载统计失败', e) }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f0f2f5;">
|
||||
<n-config-provider :theme-overrides="themeOverrides">
|
||||
<n-card style="width: 380px" :bordered="false" size="large">
|
||||
<h1 style="text-align: center; margin: 0 0 30px; font-size: 22px; color: #111827">呼籁后台管理</h1>
|
||||
<n-form @submit.prevent="handleLogin">
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="username" placeholder="请输入用户名" autofocus />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input v-model:value="password" type="password" show-password-on="click" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||
</n-form-item>
|
||||
<n-alert v-if="error" type="error" style="margin-bottom: 16px">{{ error }}</n-alert>
|
||||
<n-button type="primary" block :loading="loading" @click="handleLogin">登录</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NConfigProvider, NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const { login, isLoggedIn } = useAdmin()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
navigateTo('/admin')
|
||||
}
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#3a7d44',
|
||||
primaryColorHover: '#2d6235',
|
||||
primaryColorPressed: '#245c2b',
|
||||
primaryColorSuppl: '#3a7d44',
|
||||
},
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await login(username.value, password.value)
|
||||
navigateTo('/admin')
|
||||
} catch (e) {
|
||||
error.value = e.data?.message || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<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">管理官网顶部导航和底部导航的菜单结构</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button secondary @click="loadData">重置</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveNavigation">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="!loaded">
|
||||
<!-- 顶部导航 -->
|
||||
<n-card title="顶部导航 (Header)">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addHeaderItem">添加菜单项</n-button>
|
||||
</template>
|
||||
|
||||
<div v-for="(item, idx) in headerItems" :key="idx" style="border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 10px; overflow: hidden;">
|
||||
<n-space align="center" justify="space-between" style="padding: 10px 12px; background: #fafbfc;">
|
||||
<n-space>
|
||||
<n-input v-model:value="item.text" placeholder="文字" size="small" style="width: 140px" />
|
||||
<n-input v-model:value="item.to" placeholder="链接" size="small" style="width: 160px" />
|
||||
</n-space>
|
||||
<n-space size="small">
|
||||
<n-button size="tiny" quaternary @click="addChild(item)">+ 子菜单</n-button>
|
||||
<n-button size="tiny" quaternary @click="moveUp(headerItems, idx)" :disabled="idx === 0">上</n-button>
|
||||
<n-button size="tiny" quaternary @click="moveDown(headerItems, idx)" :disabled="idx === headerItems.length - 1">下</n-button>
|
||||
<n-button size="tiny" type="error" quaternary @click="headerItems.splice(idx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
<div v-if="item.children?.length" style="padding: 8px 12px 8px 32px; border-top: 1px solid #f0f0f0;">
|
||||
<n-space v-for="(child, cidx) in item.children" :key="cidx" align="center" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="child.text" placeholder="文字" size="small" style="width: 120px" />
|
||||
<n-input v-model:value="child.to" placeholder="链接" size="small" style="width: 140px" />
|
||||
<n-input v-model:value="child.season" placeholder="季节" size="small" style="width: 70px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="item.children.splice(cidx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<n-card title="底部导航 (Footer)" style="margin-top: 16px">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="addFooterGroup">添加分组</n-button>
|
||||
</template>
|
||||
|
||||
<div v-for="(group, gidx) in footerItems" :key="gidx" style="border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 12px; padding: 12px;">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="group.title" placeholder="分组标题" size="small" style="width: 200px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="footerItems.splice(gidx, 1)">删除分组</n-button>
|
||||
</n-space>
|
||||
<div style="padding-left: 16px;">
|
||||
<n-space v-for="(link, lidx) in group.links" :key="lidx" align="center" style="margin-bottom: 6px;">
|
||||
<n-input v-model:value="link.text" placeholder="文字" size="small" style="width: 120px" />
|
||||
<n-input v-model:value="link.to" placeholder="链接" size="small" style="width: 160px" />
|
||||
<n-button size="tiny" type="error" quaternary @click="group.links.splice(lidx, 1)">删除</n-button>
|
||||
</n-space>
|
||||
<n-button size="tiny" dashed @click="group.links.push({ text: '', to: '' })">+ 添加链接</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</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()
|
||||
|
||||
const headerItems = ref([])
|
||||
const footerItems = ref([])
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
loaded.value = false
|
||||
const data = await adminFetch('/api/admin/site-content?key=navigation')
|
||||
headerItems.value = JSON.parse(JSON.stringify(data.data.header || []))
|
||||
footerItems.value = JSON.parse(JSON.stringify(data.data.footer || []))
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function addHeaderItem() { headerItems.value.push({ text: '', to: '/' }) }
|
||||
function addChild(item) { if (!item.children) item.children = []; item.children.push({ text: '', to: '' }) }
|
||||
function addFooterGroup() { footerItems.value.push({ title: '', links: [{ text: '', to: '' }] }) }
|
||||
|
||||
function moveUp(arr, idx) { if (idx > 0) { [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]] } }
|
||||
function moveDown(arr, idx) { if (idx < arr.length - 1) { [arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]] } }
|
||||
|
||||
async function saveNavigation() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'navigation', data: { header: headerItems.value, footer: footerItems.value } },
|
||||
})
|
||||
message.success('导航菜单已保存')
|
||||
} catch (e) { message.error('保存失败:' + (e.data?.message || e.message)) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item @click="navigateTo('/admin/products')">产品线管理</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ meta?.name || route.params.key }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">
|
||||
{{ meta?.name || route.params.key }}
|
||||
<n-tag v-if="meta?.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ meta.version }}</n-tag>
|
||||
<n-tag v-if="meta?.type" size="small" :bordered="false" style="margin-left: 4px">{{ typeLabel }}</n-tag>
|
||||
</h1>
|
||||
<n-space>
|
||||
<n-button secondary @click="navigateTo('/admin/products')">返回</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<template v-if="data">
|
||||
<!-- ============ TOUR 类型编辑器(夏/秋/冬季线路产品)============ -->
|
||||
<n-tabs v-if="editorType === 'tour'" type="line" animated>
|
||||
<n-tab-pane name="basic" tab="基本信息">
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="品牌"><n-input v-model:value="data.brand" placeholder="额吉的故乡" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="版本号"><n-input v-model:value="data.version" placeholder="V9" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="季节"><n-input v-model:value="data.season" placeholder="summer/autumn/winter" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="产品介绍"><n-input v-model:value="data.narrative" type="textarea" :rows="4" /></n-form-item>
|
||||
<n-form-item label="产品风格"><n-input v-model:value="data.style" placeholder="产品风格描述" /></n-form-item>
|
||||
<n-form-item label="价格哲学"><n-input v-model:value="data.pricingPhilosophy" type="textarea" :rows="3" placeholder="价格透明说明" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<!-- 产品亮点 -->
|
||||
<n-card title="产品亮点" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'highlights', {title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(h, i) in data.highlights" :key="i" style="margin-bottom: 12px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px;">
|
||||
<n-space justify="space-between" style="margin-bottom: 8px"><span style="font-weight:500">亮点 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.highlights.splice(i,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="h.title" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="描述" :show-feedback="false"><n-input v-model:value="h.description" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
</n-card>
|
||||
<!-- 南线/北线特色(秋冬) -->
|
||||
<template v-if="data.southHighlights !== undefined">
|
||||
<n-card title="南线特色" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'southHighlights', {title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(h, i) in data.southHighlights" :key="i" style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center;">
|
||||
<n-input v-model:value="h.title" placeholder="标题" style="width: 200px" /><n-input v-model:value="h.description" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.southHighlights.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card title="北线特色" style="margin-top: 16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'northHighlights', {title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(h, i) in data.northHighlights" :key="i" style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center;">
|
||||
<n-input v-model:value="h.title" placeholder="标题" style="width: 200px" /><n-input v-model:value="h.description" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.northHighlights.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 版本管理 -->
|
||||
<n-tab-pane name="versions" tab="版本管理">
|
||||
<n-space style="margin-bottom: 16px"><n-button type="primary" @click="addVersion">新增版本</n-button></n-space>
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(ver, vi) in data.versions" :key="vi" :name="vi">
|
||||
<template #header><n-space align="center"><strong>{{ ver.name || '版本 '+(vi+1) }}</strong><n-tag v-if="ver.tag" size="small" :bordered="false">{{ ver.tag }}</n-tag><span style="color:#9ca3af;font-size:12px">{{ ver.days }}天{{ ver.nights }}晚</span></n-space></template>
|
||||
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.versions.splice(vi,1)">删除</n-button></template>
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="版本ID"><n-input v-model:value="ver.id" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="名称"><n-input v-model:value="ver.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="标签"><n-input v-model:value="ver.tag" /></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.days" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="晚数"><n-input-number v-model:value="ver.nights" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="线路"><n-input v-model:value="ver.line" placeholder="南线/北线" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="受众"><n-input v-model:value="ver.audience" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="路线"><n-input v-model:value="ver.route" /></n-form-item>
|
||||
<n-form-item label="描述"><n-input v-model:value="ver.description" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="亮点"><n-dynamic-input v-model:value="ver.highlights" placeholder="输入亮点" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<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>
|
||||
</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>
|
||||
</n-card>
|
||||
<n-card title="费用说明" style="margin-top:12px">
|
||||
<n-form-item label="住宿"><n-input v-model:value="ver.included.accommodation" /></n-form-item>
|
||||
<n-form-item label="交通"><n-input v-model:value="ver.included.transport" /></n-form-item>
|
||||
<n-form-item label="门票"><n-input v-model:value="ver.included.tickets" /></n-form-item>
|
||||
<n-form-item label="活动"><n-input v-model:value="ver.included.activities" /></n-form-item>
|
||||
</n-card>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-empty v-if="!data.versions?.length" description="暂无版本" style="padding:40px 0" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 选择指南 -->
|
||||
<n-tab-pane name="guide" tab="选择指南">
|
||||
<n-card title="按假期长度推荐">
|
||||
<template #header-extra><n-button size="small" @click="ensureArray(data, 'selectionGuide.byVacationLength'); data.selectionGuide.byVacationLength.push({condition:'',recommendation:'',reason:''})">添加</n-button></template>
|
||||
<div v-for="(g, i) in data.selectionGuide?.byVacationLength || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
|
||||
<n-input v-model:value="g.condition" placeholder="条件" style="width:120px" />
|
||||
<n-input v-model:value="g.recommendation" placeholder="推荐产品ID" style="width:160px" />
|
||||
<n-input v-model:value="g.reason" placeholder="原因" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byVacationLength.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card title="版本迭代时间线" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="addToArray(data, 'timeline', {year:'',title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(t, i) in data.timeline || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
|
||||
<n-input v-model:value="t.year" placeholder="年份" style="width:80px" />
|
||||
<n-input v-model:value="t.title" placeholder="标题" style="width:160px" />
|
||||
<n-input v-model:value="t.description" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.timeline.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="faq" tab="常见问答">
|
||||
<faq-editor :faq="data.faq" @update="data.faq = $event" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- ============ CAMP 类型编辑器(夏令营/冬令营)============ -->
|
||||
<n-tabs v-else-if="editorType === 'camp'" type="line" animated>
|
||||
<n-tab-pane name="basic" tab="基本信息">
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="营地定位"><n-input v-model:value="data.positioning" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="天数"><n-input-number v-model:value="data.days" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="晚数"><n-input-number v-model:value="data.nights" :min="0" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="每期家庭数"><n-input-number v-model:value="data.maxFamilies" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="总期数"><n-input-number v-model:value="data.totalSessions" :min="1" style="width:100%" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="适龄范围"><n-input v-model:value="data.ageRange" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="订金"><n-input v-model:value="data.deposit" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="季节"><n-input v-model:value="data.season" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="路线"><n-input v-model:value="data.route" /></n-form-item>
|
||||
<n-form-item v-if="data.whyHulunbuir !== undefined" label="为什么选呼伦贝尔"><n-input v-model:value="data.whyHulunbuir" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item v-if="data.differenceFromV9 !== undefined" label="与V9系列的区别"><n-input v-model:value="data.differenceFromV9" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item v-if="data.closingNote !== undefined" label="结语"><n-input v-model:value="data.closingNote" type="textarea" :rows="3" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<!-- 营地优势/原则 -->
|
||||
<n-card v-if="data.campAdvantages !== undefined" title="亲子营优势" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.campAdvantages.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.campAdvantages" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.campAdvantages[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.campAdvantages.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="data.principles !== undefined" title="四大原则" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.principles.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.principles" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.principles[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.principles.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="data.sessions !== undefined" title="期数统计" style="margin-top:16px">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="2025年期数"><n-input-number v-model:value="data.sessions['2025']" style="width:100%" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="2026年计划"><n-input-number v-model:value="data.sessions['2026plan']" style="width:100%" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="itinerary" tab="行程安排">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" type="primary" @click="addCampItinerary">添加行程日</n-button></template>
|
||||
<div v-for="(it, i) in data.itinerary || []" :key="i" 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 || i+1 }}</n-tag><n-button size="tiny" type="error" quaternary @click="data.itinerary.splice(i,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-form-item v-if="it.highlights" label="亮点" :show-feedback="false" style="margin-top:8px"><n-dynamic-input v-model:value="it.highlights" placeholder="行程亮点" /></n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane v-if="data.hotels !== undefined" name="hotels" tab="住宿安排">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" @click="data.hotels.push({name:'',star:'',nights:'',description:''})">添加酒店</n-button></template>
|
||||
<div v-for="(h, i) in data.hotels" :key="i" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
|
||||
<n-space justify="space-between" style="margin-bottom:8px"><span style="font-weight:500">酒店 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.hotels.splice(i,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="名称"><n-input v-model:value="h.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="星级"><n-input v-model:value="h.star" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="住宿晚数"><n-input v-model:value="h.nights" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="描述"><n-input v-model:value="h.description" type="textarea" :rows="2" /></n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane v-if="data.photographer !== undefined || data.winterClothing !== undefined || data.serviceConfig !== undefined" name="service" tab="服务配置">
|
||||
<n-card v-if="data.photographer" title="摄影师">
|
||||
<n-form-item label="姓名"><n-input v-model:value="data.photographer.name" /></n-form-item>
|
||||
<n-form-item label="介绍"><n-input v-model:value="data.photographer.description" type="textarea" :rows="2" /></n-form-item>
|
||||
<n-form-item label="特别日程"><n-input v-model:value="data.photographer.specialDay2" /></n-form-item>
|
||||
</n-card>
|
||||
<n-card v-if="data.winterClothing" title="穿衣指南" style="margin-top:16px">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="上身"><n-input v-model:value="data.winterClothing.upper" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="下身"><n-input v-model:value="data.winterClothing.lower" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="鞋子"><n-input v-model:value="data.winterClothing.shoes" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="配件"><n-input v-model:value="data.winterClothing.accessories" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
<n-card v-if="data.serviceConfig" title="随行服务" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.serviceConfig.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.serviceConfig" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.serviceConfig[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.serviceConfig.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="data.coreActivities !== undefined" title="核心体验" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.coreActivities.push('')">添加</n-button></template>
|
||||
<div v-for="(_, i) in data.coreActivities" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
|
||||
<n-input v-model:value="data.coreActivities[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.coreActivities.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="faq" tab="常见问答">
|
||||
<faq-editor :faq="data.faq" @update="data.faq = $event" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- ============ COURSE 类型编辑器(研学课程)============ -->
|
||||
<n-tabs v-else-if="editorType === 'course'" type="line" animated>
|
||||
<n-tab-pane name="philosophy" tab="教育理念">
|
||||
<n-card>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="标题"><n-input v-model:value="data.philosophy.headline" /></n-form-item>
|
||||
<n-form-item label="副标题"><n-input v-model:value="data.philosophy.tagline" /></n-form-item>
|
||||
<n-form-item label="描述"><n-input v-model:value="data.philosophy.description" type="textarea" :rows="3" /></n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<n-card title="价值观" style="margin-top:16px">
|
||||
<template #header-extra><n-button size="small" @click="data.philosophy.values.push({label:'',desc:''})">添加</n-button></template>
|
||||
<div v-for="(v, i) in data.philosophy.values" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
|
||||
<n-input v-model:value="v.label" placeholder="标签" style="width:120px" /><n-input v-model:value="v.desc" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.philosophy.values.splice(i,1)">删</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="ageGroups" tab="年龄分组">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" type="primary" @click="data.ageGroups.push({id:'',label:'',headline:'',focus:'',description:'',highlights:[],recommendedModules:[]})">添加分组</n-button></template>
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(g, gi) in data.ageGroups" :key="gi" :name="gi">
|
||||
<template #header><strong>{{ g.label || '分组 '+(gi+1) }}</strong></template>
|
||||
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.ageGroups.splice(gi,1)">删除</n-button></template>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-gi><n-form-item label="ID"><n-input v-model:value="g.id" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="标签"><n-input v-model:value="g.label" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="Emoji"><n-input v-model:value="g.emoji" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="标题"><n-input v-model:value="g.headline" /></n-form-item>
|
||||
<n-form-item label="焦点"><n-input v-model:value="g.focus" /></n-form-item>
|
||||
<n-form-item label="描述"><n-input v-model:value="g.description" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="能力清单(每行一项)"><n-input v-model:value="highlightsText[gi]" type="textarea" :rows="4" @blur="g.highlights = highlightsText[gi].split('\n').filter(Boolean)" /></n-form-item>
|
||||
<n-form-item label="推荐模块ID(逗号分隔)"><n-input :value="g.recommendedModules?.join(',')" @update:value="v => g.recommendedModules = v.split(',').map(s=>s.trim()).filter(Boolean)" /></n-form-item>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="modules" tab="课程模块">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" type="primary" @click="data.modules.push({id:'',icon:'',name:'',ageRange:'',duration:'',shortDesc:'',objectives:[],activities:[],philosophy:''})">添加模块</n-button></template>
|
||||
<n-collapse>
|
||||
<n-collapse-item v-for="(m, mi) in data.modules" :key="mi" :name="mi">
|
||||
<template #header><strong>{{ m.name || '模块 '+(mi+1) }}</strong> <span style="color:#9ca3af;font-size:12px;margin-left:8px">{{ m.ageRange }}</span></template>
|
||||
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.modules.splice(mi,1)">删除</n-button></template>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi><n-form-item label="ID"><n-input v-model:value="m.id" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="图标"><n-input v-model:value="m.icon" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="适龄"><n-input v-model:value="m.ageRange" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="时长"><n-input v-model:value="m.duration" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="名称"><n-input v-model:value="m.name" /></n-form-item>
|
||||
<n-form-item label="简述"><n-input v-model:value="m.shortDesc" type="textarea" :rows="2" /></n-form-item>
|
||||
<n-form-item label="学习目标(每行一项)"><n-input :value="m.objectives?.join('\n')" @update:value="v => m.objectives = v.split('\n').filter(Boolean)" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="活动内容(每行一项)"><n-input :value="m.activities?.join('\n')" @update:value="v => m.activities = v.split('\n').filter(Boolean)" type="textarea" :rows="3" /></n-form-item>
|
||||
<n-form-item label="教育理念引言"><n-input v-model:value="m.philosophy" type="textarea" :rows="2" /></n-form-item>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="instructors" tab="导师团队">
|
||||
<n-card>
|
||||
<template #header-extra><n-button size="small" @click="ensureObj(data,'instructors',{placeholder:[]}); data.instructors.placeholder.push({name:'',title:'',credentials:'',bio:''})">添加导师</n-button></template>
|
||||
<div v-for="(inst, i) in data.instructors?.placeholder || []" :key="i" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
|
||||
<n-space justify="space-between" style="margin-bottom:8px"><span style="font-weight:500">导师 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.instructors.placeholder.splice(i,1)">删除</n-button></n-space>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi><n-form-item label="姓名"><n-input v-model:value="inst.name" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="头衔"><n-input v-model:value="inst.title" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="资质"><n-input v-model:value="inst.credentials" /></n-form-item>
|
||||
<n-form-item label="简介"><n-input v-model:value="inst.bio" type="textarea" :rows="2" /></n-form-item>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="faq" tab="常见问答">
|
||||
<faq-editor :faq="data.faq" @update="data.faq = $event" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
</n-spin>
|
||||
|
||||
<div class="save-bar" v-if="data">
|
||||
<n-button secondary @click="navigateTo('/admin/products')">返回</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import {
|
||||
NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NForm, NFormItem,
|
||||
NInput, NInputNumber, NBreadcrumb, NBreadcrumbItem,
|
||||
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty,
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const data = ref(null)
|
||||
const meta = ref(null)
|
||||
const highlightsText = ref([])
|
||||
|
||||
const editorType = computed(() => {
|
||||
const type = meta.value?.type
|
||||
if (type === 'camp') return 'camp'
|
||||
if (type === 'course') return 'course'
|
||||
return 'tour'
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => ({ tour: '线路', camp: '营地', course: '课程' }[editorType.value] || ''))
|
||||
|
||||
// ── FAQ 子组件(内联)──
|
||||
const FaqEditor = {
|
||||
props: ['faq'],
|
||||
emits: ['update'],
|
||||
setup(props, { emit }) {
|
||||
const items = computed({
|
||||
get: () => props.faq || [],
|
||||
set: (v) => emit('update', v),
|
||||
})
|
||||
return () => h(NCard, null, {
|
||||
default: () => [
|
||||
...items.value.map((f, i) =>
|
||||
h('div', { style: 'margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;', key: i }, [
|
||||
h(NSpace, { justify: 'space-between', style: 'margin-bottom:8px' }, () => [
|
||||
h('span', { style: 'font-weight:500' }, `问答 ${i + 1}`),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => { const arr = [...items.value]; arr.splice(i, 1); emit('update', arr) } }, () => '删除'),
|
||||
]),
|
||||
h(NFormItem, { label: '问题', showFeedback: false, style: 'margin-bottom:8px' }, () => h(NInput, { value: f.question, 'onUpdate:value': v => { f.question = v } })),
|
||||
h(NFormItem, { label: '答案', showFeedback: false }, () => h(NInput, { value: f.answer, 'onUpdate:value': v => { f.answer = v }, type: 'textarea', rows: 3 })),
|
||||
])
|
||||
),
|
||||
h(NButton, { dashed: true, block: true, onClick: () => emit('update', [...items.value, { question: '', answer: '' }]) }, () => '添加问答'),
|
||||
],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ── 数据加载 ──
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const plRes = await adminFetch('/api/admin/site-content?key=product-lines')
|
||||
const productLines = typeof plRes.data === 'string' ? JSON.parse(plRes.data) : plRes.data
|
||||
meta.value = productLines?.find(p => p.key === route.params.key)
|
||||
|
||||
const res = await adminFetch(`/api/admin/site-content?key=${route.params.key}`)
|
||||
const raw = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
data.value = normalizeData(raw)
|
||||
|
||||
// Init highlights text for course ageGroups
|
||||
if (data.value.ageGroups) {
|
||||
highlightsText.value = data.value.ageGroups.map(g => (g.highlights || []).join('\n'))
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('加载失败: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeData(raw) {
|
||||
const d = { ...raw }
|
||||
// Normalize FAQ
|
||||
if (Array.isArray(d.faq)) {
|
||||
d.faq = d.faq.map(f => ({ question: f.question || f.q || '', answer: f.answer || f.a || '' }))
|
||||
} else {
|
||||
d.faq = []
|
||||
}
|
||||
// Normalize highlights
|
||||
if (Array.isArray(d.highlights)) {
|
||||
d.highlights = d.highlights.map(h => typeof h === 'string' ? { title: h, description: '' } : h)
|
||||
}
|
||||
// Normalize versions (tour type)
|
||||
if (Array.isArray(d.versions)) {
|
||||
d.versions = d.versions.map(v => normalizeVersion(v))
|
||||
}
|
||||
// Ensure objects
|
||||
if (!d.selectionGuide) d.selectionGuide = {}
|
||||
if (!d.philosophy && editorType.value === 'course') d.philosophy = { headline: '', tagline: '', description: '', values: [] }
|
||||
if (!d.ageGroups && editorType.value === 'course') d.ageGroups = []
|
||||
if (!d.modules && editorType.value === 'course') d.modules = []
|
||||
return d
|
||||
}
|
||||
|
||||
function normalizeVersion(v) {
|
||||
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 || '',
|
||||
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 || [],
|
||||
},
|
||||
included: {
|
||||
accommodation: v.included?.accommodation || '', transport: v.included?.transport || '',
|
||||
tickets: v.included?.tickets || '', activities: v.included?.activities || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeItinerary(items) {
|
||||
return items.map((item, i) => {
|
||||
if (typeof item === 'string') {
|
||||
const match = item.match(/^(?:D|Day)\s*(\d+)\s*(.*)$/i)
|
||||
const day = match ? parseInt(match[1]) : i + 1
|
||||
const rest = match ? match[2].trim() : item
|
||||
return { day, title: rest, summary: '', hotel: '' }
|
||||
}
|
||||
return { day: item.day || i + 1, title: item.title || '', summary: item.summary || '', hotel: item.hotel || '', highlights: item.highlights }
|
||||
})
|
||||
}
|
||||
|
||||
// ── 工具函数 ──
|
||||
function addVersion() {
|
||||
if (!data.value.versions) data.value.versions = []
|
||||
data.value.versions.push(normalizeVersion({ id: '', name: '', days: 5, nights: 4 }))
|
||||
}
|
||||
function addItineraryDay(ver) {
|
||||
if (!ver.itinerary) ver.itinerary = []
|
||||
ver.itinerary.push({ day: ver.itinerary.length + 1, title: '', summary: '', hotel: '' })
|
||||
}
|
||||
function addCampItinerary() {
|
||||
if (!data.value.itinerary) data.value.itinerary = []
|
||||
data.value.itinerary.push({ day: data.value.itinerary.length + 1, title: '', summary: '', hotel: '', highlights: [] })
|
||||
}
|
||||
function addToArray(obj, key, item) {
|
||||
if (!obj[key]) obj[key] = []
|
||||
obj[key].push(typeof item === 'object' ? { ...item } : item)
|
||||
}
|
||||
function ensureArray(obj, path) {
|
||||
const parts = path.split('.')
|
||||
let cur = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]]) cur[parts[i]] = {}
|
||||
cur = cur[parts[i]]
|
||||
}
|
||||
if (!Array.isArray(cur[parts[parts.length - 1]])) cur[parts[parts.length - 1]] = []
|
||||
}
|
||||
function ensureObj(obj, key, defaultVal) {
|
||||
if (!obj[key]) obj[key] = defaultVal
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: route.params.key, data: data.value },
|
||||
})
|
||||
message.success('保存成功')
|
||||
} catch (e) {
|
||||
message.error('保存失败: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理所有产品线 · 支持动态增删</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="showAddModal = true">新增产品线</n-button>
|
||||
</n-space>
|
||||
|
||||
<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-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>
|
||||
<div>
|
||||
<div style="font-size: 16px; font-weight: 600; color: #111827">
|
||||
{{ 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>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px">
|
||||
key: {{ item.key }} · 排序: {{ item.sortOrder }}
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button size="small" @click="moveUp(index)" :disabled="index === 0">上移</n-button>
|
||||
<n-button size="small" @click="moveDown(index)" :disabled="index === productLines.length - 1">下移</n-button>
|
||||
<n-button size="small" type="primary" @click="navigateTo(`/admin/products/${item.key}`)">编辑内容</n-button>
|
||||
<n-popconfirm @positive-click="handleDelete(index)">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" quaternary>删除</n-button>
|
||||
</template>
|
||||
确定删除「{{ item.name }}」产品线?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-empty v-if="!loading && productLines.length === 0" description="暂无产品线" style="padding: 60px 0" />
|
||||
</n-spin>
|
||||
|
||||
<!-- 新增弹窗 -->
|
||||
<n-modal v-model:show="showAddModal" preset="dialog" title="新增产品线" positive-text="确定" negative-text="取消" @positive-click="handleAdd">
|
||||
<n-form label-placement="top" style="margin-top: 16px">
|
||||
<n-form-item label="产品线名称" required>
|
||||
<n-input v-model:value="newItem.name" placeholder="如:额吉的故乡" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="Key(唯一标识)" required>
|
||||
<n-input v-model:value="newItem.key" placeholder="如:spring-products" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="版本号">
|
||||
<n-input v-model:value="newItem.version" placeholder="如:V1" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="季节">
|
||||
<n-select v-model:value="newItem.season" :options="seasonOptions" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="类型">
|
||||
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }]" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NEmpty, NModal, NForm, NFormItem, NInput, NSelect, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const productLines = ref([])
|
||||
const showAddModal = ref(false)
|
||||
|
||||
const seasonOptions = [
|
||||
{ label: '春季', value: 'spring' },
|
||||
{ label: '夏季', value: 'summer' },
|
||||
{ label: '秋季', value: 'autumn' },
|
||||
{ label: '冬季', value: 'winter' },
|
||||
{ label: '全年', value: 'all' },
|
||||
]
|
||||
|
||||
const newItem = ref({ name: '', key: '', version: '', season: 'summer', type: 'tour' })
|
||||
|
||||
function seasonLabel(s) {
|
||||
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬', all: '全年' }[s] || s
|
||||
}
|
||||
|
||||
function seasonType(s) {
|
||||
return { spring: 'success', summer: 'success', autumn: 'warning', winter: 'info', all: 'default' }[s] || 'default'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=product-lines')
|
||||
productLines.value = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || [])
|
||||
} catch { message.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function saveList() {
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'product-lines', data: productLines.value },
|
||||
})
|
||||
} catch { message.error('保存失败') }
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newItem.value.name?.trim() || !newItem.value.key?.trim()) {
|
||||
message.warning('名称和 Key 不能为空')
|
||||
return false
|
||||
}
|
||||
if (productLines.value.some(p => p.key === newItem.value.key)) {
|
||||
message.warning('Key 已存在')
|
||||
return false
|
||||
}
|
||||
|
||||
// Add to list
|
||||
productLines.value.push({
|
||||
...newItem.value,
|
||||
sortOrder: productLines.value.length + 1,
|
||||
})
|
||||
await saveList()
|
||||
|
||||
// Create empty product data entry in site_content
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
key: newItem.value.key,
|
||||
label: newItem.value.name,
|
||||
data: {
|
||||
narrative: '',
|
||||
highlights: [],
|
||||
versions: [],
|
||||
faq: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
message.success('已添加')
|
||||
newItem.value = { name: '', key: '', version: '', season: 'summer', type: 'tour', icon: '🌿' }
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
const arr = productLines.value
|
||||
;[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]
|
||||
arr.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
saveList()
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
if (index >= productLines.value.length - 1) return
|
||||
const arr = productLines.value
|
||||
;[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
|
||||
arr.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
saveList()
|
||||
}
|
||||
|
||||
async function handleDelete(index) {
|
||||
productLines.value.splice(index, 1)
|
||||
productLines.value.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
await saveList()
|
||||
message.success('已删除')
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="edit-page">
|
||||
<!-- 面包屑 -->
|
||||
<NBreadcrumb class="page-breadcrumb">
|
||||
<NBreadcrumbItem>
|
||||
<NuxtLink to="/admin/reviews">客户评价</NuxtLink>
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem>{{ isNew ? '新增' : '编辑' }}</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<h1>{{ isNew ? '新增评价' : '编辑评价' }}</h1>
|
||||
<div class="header-actions">
|
||||
<NButton @click="navigateTo('/admin/reviews')">取消</NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载 / 错误状态 -->
|
||||
<div v-if="loadError" style="color: #ef4444; padding: 16px;">{{ loadError }}</div>
|
||||
<div v-else-if="pageLoading" style="text-align: center; padding: 60px;">
|
||||
<NSpin size="large" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 双栏布局 -->
|
||||
<NGrid :cols="4" :x-gap="20" :y-gap="20" class="form-grid">
|
||||
<!-- 主区(3列) -->
|
||||
<NGi :span="3">
|
||||
<NCard title="基本信息">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="客户" required>
|
||||
<NInput v-model:value="form.author" placeholder="输入客户名称" />
|
||||
</NFormItem>
|
||||
<NFormItem label="评分">
|
||||
<NRate v-model:value="form.rating" :count="5" />
|
||||
</NFormItem>
|
||||
<NFormItem label="产品版本">
|
||||
<NInput v-model:value="form.title" placeholder="输入产品版本" />
|
||||
</NFormItem>
|
||||
<NFormItem label="出行日期">
|
||||
<NInput v-model:value="form.date" placeholder="如:2026年3月" />
|
||||
</NFormItem>
|
||||
<NFormItem label="内容" required>
|
||||
<NInput
|
||||
v-model:value="form.content"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="输入评价内容"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
|
||||
<!-- 侧栏(1列) -->
|
||||
<NGi :span="1">
|
||||
<NCard title="发布设置">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="状态">
|
||||
<NSelect
|
||||
v-model:value="form.published"
|
||||
:options="publishedOptions"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="已验证">
|
||||
<NSwitch v-model:value="form.verified" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<!-- 底部 sticky 保存栏 -->
|
||||
<div class="save-bar">
|
||||
<NButton @click="navigateTo('/admin/reviews')">取消</NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="saving" @click="handleSave">
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
NButton, NCard, NForm, NFormItem, NInput, NSelect, NSwitch, NRate,
|
||||
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, useMessage,
|
||||
} from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
author: '',
|
||||
title: '',
|
||||
date: '',
|
||||
content: '',
|
||||
rating: 5,
|
||||
published: true,
|
||||
verified: false,
|
||||
})
|
||||
|
||||
const publishedOptions = [
|
||||
{ label: '已发布', value: true },
|
||||
{ label: '草稿', value: false },
|
||||
]
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/reviews/${route.params.id}`)
|
||||
form.value = {
|
||||
...data,
|
||||
rating: data.rating ?? 5,
|
||||
published: !!data.published,
|
||||
verified: !!data.verified,
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e.data?.message || '加载失败'
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.author?.trim()) { message.error('客户名称不能为空'); return }
|
||||
if (!form.value.content?.trim()) { message.error('内容不能为空'); return }
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/reviews', { method: 'POST', body: form.value })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/reviews'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/reviews/${route.params.id}`, { method: 'PUT', body: form.value })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-page {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.page-breadcrumb {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
margin-top: 24px;
|
||||
background: #f0f2f5;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理客户评价 · 对应官网「客户评价」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/reviews" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/reviews/new')">新增评价</n-button>
|
||||
</n-space>
|
||||
</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()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<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-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm, NRate } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
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 columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
title: '客户', key: 'author',
|
||||
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,
|
||||
render: (row) => h(NRate, { value: row.rating || 5, readonly: true, size: 'small' }),
|
||||
},
|
||||
{ title: '产品版本', key: 'title', width: 100 },
|
||||
{
|
||||
title: '内容摘要', key: 'content',
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.content?.slice(0, 50)),
|
||||
},
|
||||
{ title: '出行日期', key: 'date', width: 90, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.date) },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small', type: row.published ? 'success' : 'default', bordered: false,
|
||||
style: 'cursor: pointer',
|
||||
onClick: () => togglePublish(row),
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
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) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.author}」的评价?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
} catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'PUT', body: { published: !item.published } })
|
||||
item.published = !item.published
|
||||
} catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'DELETE' })
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
} catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div style="max-width: 1100px">
|
||||
<!-- 面包屑 -->
|
||||
<n-breadcrumb style="margin-bottom: 16px">
|
||||
<n-breadcrumb-item>
|
||||
<NuxtLink to="/admin/stories">客户故事</NuxtLink>
|
||||
</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ isNew ? '新增' : '编辑' }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
|
||||
<!-- 页头 -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">
|
||||
{{ isNew ? '新增故事' : '编辑故事' }}
|
||||
</h1>
|
||||
<n-space>
|
||||
<NuxtLink to="/admin/stories">
|
||||
<n-button>取消</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- 加载/错误状态 -->
|
||||
<n-card v-if="loadError" style="text-align: center; color: #ef4444; padding: 40px">
|
||||
{{ loadError }}
|
||||
</n-card>
|
||||
<n-card v-else-if="pageLoading" style="text-align: center; padding: 60px; color: #9ca3af">
|
||||
加载中...
|
||||
</n-card>
|
||||
|
||||
<template v-else>
|
||||
<!-- 双栏布局 -->
|
||||
<n-grid :cols="4" :x-gap="20" :y-gap="0" style="align-items: start">
|
||||
<!-- 主内容区(占 3 列) -->
|
||||
<n-gi :span="3">
|
||||
<!-- 基本信息 -->
|
||||
<n-card title="基本信息">
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-form-item label="标题" required>
|
||||
<n-input v-model:value="form.title" placeholder="输入故事标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Slug" required>
|
||||
<n-input v-model:value="form.slug" placeholder="URL 标识,如 wang-family-trip" />
|
||||
</n-form-item>
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="form.subtitle" placeholder="输入副标题(可选)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 家庭信息 -->
|
||||
<n-card title="家庭信息" style="margin-top: 16px">
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-form-item label="家庭类型">
|
||||
<n-input v-model:value="form.familyType" placeholder="如:三口之家" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="孩子年龄">
|
||||
<n-input v-model:value="form.childrenAge" placeholder="如:5岁" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="出发地">
|
||||
<n-input v-model:value="form.fromCity" placeholder="如:上海" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="出行日期">
|
||||
<n-input v-model:value="form.travelDate" placeholder="如:2026年3月" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="产品">
|
||||
<n-input v-model:value="form.tripProduct" placeholder="如:呼伦贝尔亲子6日游" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 故事章节 -->
|
||||
<n-card title="故事章节" style="margin-top: 16px">
|
||||
<div
|
||||
v-for="(sec, i) in form.sections"
|
||||
:key="i"
|
||||
style="margin-bottom: 16px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px"
|
||||
>
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 8px">
|
||||
<span style="font-weight: 500">章节 {{ i + 1 }}</span>
|
||||
<n-button size="tiny" type="error" quaternary @click="form.sections.splice(i, 1)">
|
||||
删除
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="sec.heading" placeholder="章节标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="内容">
|
||||
<n-input
|
||||
v-model:value="sec.content"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="章节内容"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<n-button dashed block @click="form.sections.push({ heading: '', content: '' })">
|
||||
添加章节
|
||||
</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 精彩瞬间 -->
|
||||
<n-card title="精彩瞬间" style="margin-top: 16px">
|
||||
<n-dynamic-input
|
||||
v-model:value="form.keyMoments"
|
||||
placeholder="输入一个精彩瞬间描述"
|
||||
:min="0"
|
||||
show-sort-button
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<!-- 侧栏(占 1 列) -->
|
||||
<n-gi :span="1">
|
||||
<n-card title="发布设置">
|
||||
<n-form label-placement="top" :show-feedback="false">
|
||||
<n-form-item label="状态">
|
||||
<n-select
|
||||
v-model:value="form.published"
|
||||
:options="[
|
||||
{ label: '已发布', value: true },
|
||||
{ label: '草稿', value: false },
|
||||
]"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="封面图" style="margin-top: 16px">
|
||||
<AdminImageUpload v-model="form.coverImage" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 底部 sticky 保存栏 -->
|
||||
<div
|
||||
style="
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
margin-top: 24px;
|
||||
background: #f0f2f5;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
"
|
||||
>
|
||||
<NuxtLink to="/admin/stories">
|
||||
<n-button>取消</n-button>
|
||||
</NuxtLink>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
|
||||
const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
slug: '',
|
||||
subtitle: '',
|
||||
familyType: '',
|
||||
childrenAge: '',
|
||||
fromCity: '',
|
||||
travelDate: '',
|
||||
tripProduct: '',
|
||||
published: true,
|
||||
coverImage: '',
|
||||
sections: [],
|
||||
keyMoments: [],
|
||||
})
|
||||
|
||||
// 加载数据(编辑模式)
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/stories/${route.params.id}`)
|
||||
form.value = {
|
||||
...data,
|
||||
published: !!data.published,
|
||||
coverImage: data.coverImage || '',
|
||||
sections:
|
||||
typeof data.sections === 'string'
|
||||
? JSON.parse(data.sections || '[]')
|
||||
: data.sections || [],
|
||||
keyMoments:
|
||||
typeof data.keyMoments === 'string'
|
||||
? JSON.parse(data.keyMoments || '[]')
|
||||
: data.keyMoments || [],
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e.data?.message || '加载失败'
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title?.trim()) {
|
||||
message.error('标题不能为空')
|
||||
return
|
||||
}
|
||||
if (!form.value.slug?.trim()) {
|
||||
message.error('Slug 不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
const payload = {
|
||||
...form.value,
|
||||
sections: JSON.stringify(form.value.sections),
|
||||
keyMoments: JSON.stringify(form.value.keyMoments),
|
||||
}
|
||||
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await adminFetch('/api/admin/stories', { method: 'POST', body: payload })
|
||||
message.success('创建成功')
|
||||
setTimeout(() => navigateTo('/admin/stories'), 800)
|
||||
} else {
|
||||
await adminFetch(`/api/admin/stories/${route.params.id}`, { method: 'PUT', body: payload })
|
||||
message.success('保存成功')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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">管理客户旅行故事 · 对应官网「客户故事」页面</p>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button tag="a" href="/stories" target="_blank" secondary>查看前台</n-button>
|
||||
<n-button type="primary" @click="navigateTo('/admin/stories/new')">新增故事</n-button>
|
||||
</n-space>
|
||||
</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()">
|
||||
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
||||
</n-input>
|
||||
<n-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
|
||||
<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-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
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 columns = [
|
||||
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
||||
{
|
||||
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),
|
||||
},
|
||||
{ title: '家庭类型', key: 'familyType', width: 90, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.familyType || '-') },
|
||||
{ title: '出发地', key: 'fromCity', width: 80, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.fromCity || '-') },
|
||||
{ title: '产品', key: 'tripProduct', width: 100, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.tripProduct || '-') },
|
||||
{
|
||||
title: '状态', key: 'published', width: 70,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.published ? 'success' : 'default', bordered: false, style: 'cursor: pointer', onClick: () => togglePublish(row) }, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/stories/${row.id}`) }, () => '编辑'),
|
||||
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
||||
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
||||
default: () => `确定删除「${row.title}」?`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try { const d = await adminFetch(`/api/admin/stories?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`); list.value = d.items; total.value = d.total; totalPages.value = d.totalPages }
|
||||
catch { message.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function togglePublish(item) {
|
||||
try { await adminFetch(`/api/admin/stories/${item.id}`, { method: 'PUT', body: { published: !item.published } }); item.published = !item.published }
|
||||
catch { message.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try { await adminFetch(`/api/admin/stories/${item.id}`, { method: 'DELETE' }); message.success('已删除'); loadData() }
|
||||
catch { message.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<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>
|
||||
</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>
|
||||
<NInput
|
||||
v-model:value="search"
|
||||
placeholder="搜索姓名、手机..."
|
||||
clearable
|
||||
style="width: 240px;"
|
||||
@update:value="onSearch"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
/>
|
||||
|
||||
<NSpace justify="end" v-if="totalPages > 1">
|
||||
<NPagination
|
||||
:page="page"
|
||||
:page-count="totalPages"
|
||||
:page-size="20"
|
||||
@update:page="onPageChange"
|
||||
/>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<NDrawer v-model:show="drawerVisible" :width="480">
|
||||
<NDrawerContent title="提交详情" closable>
|
||||
<template v-if="currentItem">
|
||||
<NDescriptions bordered :column="1" label-placement="left">
|
||||
<NDescriptionsItem label="ID">{{ currentItem.id }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="类型">
|
||||
<NTag :type="currentItem.type === 'contact' ? 'info' : 'warning'" size="small">
|
||||
{{ currentItem.type === 'contact' ? '留言' : '定制' }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<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="内容">
|
||||
<div style="white-space: pre-wrap;">{{ currentItem.data?.message }}</div>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
<NTag :type="currentItem.read ? 'success' : 'warning'" size="small">
|
||||
{{ currentItem.read ? '已读' : '未读' }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="提交时间">{{ formatTime(currentItem.createdAt) }}</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">
|
||||
<NDescriptionsItem v-for="field in extraFields" :key="field.key" :label="field.key">
|
||||
{{ field.value }}
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<NButton
|
||||
v-if="currentItem && !currentItem.read"
|
||||
type="primary"
|
||||
@click="markRead(currentItem)"
|
||||
>标记已读</NButton>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</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'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
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 filterType = ref('')
|
||||
const drawerVisible = ref(false)
|
||||
const currentItem = ref(null)
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '留言', value: 'contact' },
|
||||
{ label: '定制', value: 'customize' },
|
||||
]
|
||||
|
||||
const extraFields = computed(() => {
|
||||
if (!currentItem.value?.data) return []
|
||||
const knownKeys = ['name', 'phone', 'email', 'message']
|
||||
return Object.entries(currentItem.value.data)
|
||||
.filter(([k]) => !knownKeys.includes(k))
|
||||
.map(([key, value]) => ({ key, value: typeof value === 'object' ? JSON.stringify(value) : String(value) }))
|
||||
})
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
return t.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
render(row) {
|
||||
return 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: 'name',
|
||||
width: 100,
|
||||
render(row) {
|
||||
return row.data?.name || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '手机',
|
||||
key: 'phone',
|
||||
width: 130,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
key: 'message',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
const msg = row.data?.message || ''
|
||||
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: 'read',
|
||||
width: 70,
|
||||
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,
|
||||
])
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
let searchTimer = null
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
let url = `/api/admin/submissions?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`
|
||||
if (filterType.value) url += `&type=${filterType.value}`
|
||||
const d = await adminFetch(url)
|
||||
list.value = d.items
|
||||
total.value = d.total
|
||||
totalPages.value = d.totalPages
|
||||
} catch (e) {
|
||||
message.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onPageChange(p) {
|
||||
page.value = p
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onFilterChange(type) {
|
||||
filterType.value = type
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentItem.value = row
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function markRead(row) {
|
||||
try {
|
||||
await adminFetch(`/api/admin/submissions/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: { read: true },
|
||||
})
|
||||
row.read = true
|
||||
message.success('已标记为已读')
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<div v-if="article" class="page-blog-detail">
|
||||
<LayoutPageHero
|
||||
:title="article.title"
|
||||
:subtitle="article.subtitle"
|
||||
:breadcrumbs="[{ text: '攻略', to: '/blog' }, { text: article.category }]"
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="blog-layout">
|
||||
<!-- 主内容区 -->
|
||||
<main class="blog-main">
|
||||
<!-- 文章元信息 -->
|
||||
<div class="article-meta">
|
||||
<span class="article-category">{{ article.category }}</span>
|
||||
<span class="article-date">{{ formatDate(article.date) }}</span>
|
||||
<span class="article-author">作者:{{ article.author }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<div class="article-cover">
|
||||
<img
|
||||
:src="article.coverImage"
|
||||
:alt="article.title"
|
||||
class="article-cover__img"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<div class="article-summary">
|
||||
<p>{{ article.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 正文 -->
|
||||
<div class="article-content" v-html="article.content" />
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="article-tags">
|
||||
<span class="tags-label">标签:</span>
|
||||
<span v-for="tag in article.tags" :key="tag" class="article-tag">{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 相关产品 -->
|
||||
<div v-if="relatedProducts.length" class="article-products">
|
||||
<h3 class="article-products__title">推荐行程</h3>
|
||||
<div class="article-products__list">
|
||||
<NuxtLink
|
||||
v-for="prod in relatedProducts"
|
||||
:key="prod.id"
|
||||
:to="`/products/${prod.id}`"
|
||||
class="product-link"
|
||||
>
|
||||
<div class="product-link__info">
|
||||
<span class="product-link__name">{{ prod.name }}</span>
|
||||
<span class="product-link__duration">{{ prod.duration }}</span>
|
||||
</div>
|
||||
<span class="product-link__arrow">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章导航 -->
|
||||
<div class="article-nav">
|
||||
<NuxtLink v-if="prevArticle" :to="`/blog/${prevArticle.id}`" class="article-nav__item article-nav__item--prev">
|
||||
<span class="article-nav__label">上一篇</span>
|
||||
<span class="article-nav__title">{{ prevArticle.title }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="nextArticle" :to="`/blog/${nextArticle.id}`" class="article-nav__item article-nav__item--next">
|
||||
<span class="article-nav__label">下一篇</span>
|
||||
<span class="article-nav__title">{{ nextArticle.title }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="blog-sidebar">
|
||||
<!-- 联系咨询 -->
|
||||
<div class="sidebar-card sidebar-card--cta">
|
||||
<h3 class="sidebar-card__title">免费获取定制方案</h3>
|
||||
<p class="sidebar-card__desc">看完攻略有疑问?联系呼籁旅行定制师,一对一解答。</p>
|
||||
<CommonInternalLink to="/contact" text="立即咨询" />
|
||||
</div>
|
||||
|
||||
<!-- 全部文章 -->
|
||||
<div class="sidebar-card">
|
||||
<h3 class="sidebar-card__title">更多攻略</h3>
|
||||
<ul class="sidebar-articles">
|
||||
<li
|
||||
v-for="art in otherArticles"
|
||||
:key="art.id"
|
||||
class="sidebar-article"
|
||||
:class="{ 'sidebar-article--active': art.id === article.id }"
|
||||
>
|
||||
<NuxtLink :to="`/blog/${art.id}`" class="sidebar-article__link">
|
||||
<span class="sidebar-article__category">{{ art.category }}</span>
|
||||
<span class="sidebar-article__title">{{ art.title }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 快速链接 -->
|
||||
<div class="sidebar-card">
|
||||
<h3 class="sidebar-card__title">快速入口</h3>
|
||||
<div class="sidebar-links">
|
||||
<CommonInternalLink to="/products" text="查看旅行产品" />
|
||||
<CommonInternalLink to="/faq" text="常见问答" />
|
||||
<CommonInternalLink to="/guides" text="出行指南" />
|
||||
<CommonInternalLink to="/reviews" text="客户评价" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 404 -->
|
||||
<div v-else class="page-not-found">
|
||||
<div class="container">
|
||||
<h1>文章不存在</h1>
|
||||
<NuxtLink to="/blog">返回攻略列表</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
import useScrollReveal from '~/composables/useScrollReveal'
|
||||
|
||||
const { data: blogData } = await usePublicApi('blog')
|
||||
const { data: productsData } = await usePublicApi('products')
|
||||
|
||||
const route = useRoute()
|
||||
const allArticles = computed(() => blogData.value?.articles || [])
|
||||
const article = computed(() => allArticles.value.find(a => a.id === route.params.id))
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: '文章不存在' })
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: article.value?.seo?.title || article.value?.title,
|
||||
meta: [
|
||||
{ name: 'description', content: article.value?.seo?.description || article.value?.summary },
|
||||
{ name: 'keywords', content: article.value?.seo?.keywords || (article.value?.tags || []).join(',') },
|
||||
{ name: 'author', content: article.value?.author },
|
||||
{ name: 'article:published_time', content: (article.value?.date || '') + 'T00:00:00+08:00' },
|
||||
{ name: 'article:modified_time', content: (article.value?.date || '') + 'T00:00:00+08:00' },
|
||||
{ name: 'geo.region', content: 'CN-NM' },
|
||||
{ name: 'geo.placename', content: '呼伦贝尔' },
|
||||
{ property: 'og:title', content: article.value?.seo?.title || article.value?.title },
|
||||
{ property: 'og:description', content: article.value?.seo?.description || article.value?.summary },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:url', content: `https://1814.love/blog/${article.value?.id}` },
|
||||
{ property: 'og:site_name', content: '呼籁旅行' },
|
||||
{ property: 'og:locale', content: 'zh_CN' },
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: `https://1814.love/blog/${article.value?.id}` },
|
||||
],
|
||||
})
|
||||
|
||||
useScrollReveal()
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '旅行攻略', to: '/blog' },
|
||||
{ text: article.value?.category, to: `/blog?category=${article.value?.category}` },
|
||||
{ text: article.value?.title },
|
||||
])
|
||||
|
||||
// JSON-LD Article schema
|
||||
useHead({
|
||||
script: [{
|
||||
type: 'application/ld+json',
|
||||
children: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'@id': `https://1814.love/blog/${article.value?.id}#article`,
|
||||
headline: article.value?.title,
|
||||
description: article.value?.summary,
|
||||
image: article.value?.coverImage ? `https://1814.love${article.value.coverImage}` : 'https://1814.love/images/logo.png',
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://1814.love/#organization',
|
||||
name: '呼籁旅行',
|
||||
url: 'https://1814.love',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://1814.love/#organization',
|
||||
name: '呼籁旅行',
|
||||
url: 'https://1814.love',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://1814.love/images/logo.png',
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
datePublished: article.value?.date,
|
||||
dateModified: article.value?.date,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `https://1814.love/blog/${article.value?.id}`,
|
||||
},
|
||||
inLanguage: 'zh-CN',
|
||||
keywords: (article.value?.tags || []).join(','),
|
||||
}),
|
||||
}],
|
||||
})
|
||||
|
||||
// 相关产品
|
||||
const relatedProducts = computed(() => {
|
||||
if (!article.value?.relatedProducts?.length || !productsData.value?.versions) return []
|
||||
return productsData.value.versions
|
||||
.filter(v => article.value.relatedProducts.includes(v.id))
|
||||
.map(v => ({ id: v.id, name: v.name, duration: `${v.days}天${v.nights}晚 · ${v.tag}` }))
|
||||
})
|
||||
|
||||
// 上下篇
|
||||
const currentIndex = computed(() => allArticles.value.findIndex(a => a.id === article.value?.id))
|
||||
const prevArticle = computed(() => currentIndex.value > 0 ? allArticles.value[currentIndex.value - 1] : null)
|
||||
const nextArticle = computed(() => currentIndex.value < allArticles.value.length - 1 ? allArticles.value[currentIndex.value + 1] : null)
|
||||
|
||||
// 侧边栏文章
|
||||
const otherArticles = allArticles
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.blog-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-2xl;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr 300px;
|
||||
align-items: start;
|
||||
});
|
||||
}
|
||||
|
||||
// 主内容
|
||||
.blog-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-lg;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.article-category {
|
||||
padding: 3px @space-sm;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
.article-cover {
|
||||
margin-bottom: @space-xl;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 7;
|
||||
background: @color-primary-bg;
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
padding: @space-lg;
|
||||
background: @color-primary-bg;
|
||||
border-left: 4px solid @color-primary;
|
||||
border-radius: 0 @border-radius-md @border-radius-md 0;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
}
|
||||
|
||||
// 文章正文样式
|
||||
.article-content {
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
font-size: @font-size-base;
|
||||
|
||||
:deep(h2) {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: @space-2xl 0 @space-lg;
|
||||
padding-bottom: @space-sm;
|
||||
border-bottom: 2px solid @color-primary-bg;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(h3) {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: @space-xl 0 @space-md;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 @space-lg;
|
||||
}
|
||||
|
||||
:deep(ul), :deep(ol) {
|
||||
margin: 0 0 @space-lg;
|
||||
padding-left: @space-xl;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin-bottom: @space-sm;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
:deep(strong) {
|
||||
color: @color-text-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
|
||||
:deep(br) {
|
||||
display: block;
|
||||
content: '';
|
||||
margin-top: @space-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签
|
||||
.article-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
margin-top: @space-2xl;
|
||||
padding-top: @space-lg;
|
||||
border-top: 1px solid @color-border;
|
||||
}
|
||||
|
||||
.tags-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.article-tag {
|
||||
padding: 3px @space-sm;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
}
|
||||
|
||||
// 相关产品
|
||||
.article-products {
|
||||
margin-top: @space-xl;
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-md;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.product-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: @space-md @space-lg;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
color: @color-primary;
|
||||
font-size: @font-size-md;
|
||||
transition: transform @transition-fast;
|
||||
}
|
||||
|
||||
&:hover &__arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
// 文章导航
|
||||
.article-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: @space-md;
|
||||
margin-top: @space-2xl;
|
||||
padding-top: @space-lg;
|
||||
border-top: 1px solid @color-border;
|
||||
|
||||
&__item {
|
||||
padding: @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&--next {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
}
|
||||
|
||||
// 侧边栏
|
||||
.blog-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-lg;
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-lg;
|
||||
|
||||
&--cta {
|
||||
background: @color-primary-bg;
|
||||
border-color: @color-primary-lighter;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0 0 @space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-articles {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-article {
|
||||
&--active .sidebar-article__link {
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
text-decoration: none;
|
||||
transition: background @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&__category {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
// 404
|
||||
.page-not-found {
|
||||
text-align: center;
|
||||
padding: @space-3xl 0;
|
||||
|
||||
h1 {
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
a {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<div class="page-blog">
|
||||
<LayoutPageHero
|
||||
title="旅行攻略"
|
||||
subtitle="呼伦贝尔旅行的干货指南,帮你把每一趟出行都过得值"
|
||||
:breadcrumbs="[{ text: '攻略' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="旅行攻略摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔旅行攻略合集:包含穿搭指南、行程规划、亲子活动推荐、避坑经验等干货内容,帮你把每一趟出行都过得值。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="blogData" class="section">
|
||||
<div class="container">
|
||||
<!-- 分类筛选 -->
|
||||
<div class="blog-filter">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="filter-btn"
|
||||
:class="{ 'filter-btn--active': activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
{{ cat.label }}
|
||||
<span class="filter-count">{{ getCategoryCount(cat.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div class="blog-grid">
|
||||
<article
|
||||
v-for="article in filteredArticles"
|
||||
:key="article.id"
|
||||
class="blog-card reveal"
|
||||
>
|
||||
<NuxtLink :to="`/blog/${article.id}`" class="blog-card__link">
|
||||
<div class="blog-card__cover">
|
||||
<img
|
||||
:src="article.coverImage"
|
||||
:alt="article.title"
|
||||
class="blog-card__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="blog-card__category">{{ article.category }}</span>
|
||||
</div>
|
||||
<div class="blog-card__body">
|
||||
<div class="blog-card__meta">
|
||||
<span class="blog-card__date">{{ formatDate(article.date) }}</span>
|
||||
<span class="blog-card__author">{{ article.author }}</span>
|
||||
</div>
|
||||
<h2 class="blog-card__title">{{ article.title }}</h2>
|
||||
<p class="blog-card__subtitle">{{ article.subtitle }}</p>
|
||||
<p class="blog-card__summary">{{ article.summary }}</p>
|
||||
<div class="blog-card__tags">
|
||||
<span v-for="tag in article.tags.slice(0, 3)" :key="tag" class="blog-tag">{{ tag }}</span>
|
||||
</div>
|
||||
<span class="blog-card__read-more">阅读全文 →</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredArticles.length === 0" class="blog-empty">
|
||||
<p>暂无该分类的文章</p>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="blog-cta">
|
||||
<h2>看完攻略,准备出发了吗?</h2>
|
||||
<p>呼籁旅行为每个家庭提供专属定制方案,告诉我们你的需求,我们帮你把行程安排得妥妥的。</p>
|
||||
<div class="blog-cta-links">
|
||||
<CommonInternalLink to="/products" text="查看定制游产品" />
|
||||
<CommonInternalLink to="/contact" text="咨询定制师" />
|
||||
<CommonInternalLink to="/faq" text="常见问答" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
import useScrollReveal from '~/composables/useScrollReveal'
|
||||
|
||||
const { data: blogData } = await usePublicApi('blog')
|
||||
|
||||
useHead({
|
||||
title: '呼伦贝尔旅行攻略 - 亲子游·避坑·穿搭·行程干货 - 呼籁旅行',
|
||||
meta: [
|
||||
{ name: 'description', content: '呼籁旅行旅行攻略频道:呼伦贝尔亲子游完全指南、几月份去最好、旅行花费明细、避坑指南、草原旅拍穿搭,深耕11年的实用干货。' },
|
||||
{ name: 'keywords', content: '呼伦贝尔旅游攻略,呼伦贝尔亲子游攻略,呼伦贝尔几月去,呼伦贝尔旅游花费,草原旅拍穿搭' },
|
||||
{ property: 'og:title', content: '呼伦贝尔旅行攻略 - 呼籁旅行' },
|
||||
{ property: 'og:description', content: '呼伦贝尔亲子游·避坑指南·穿搭攻略,深耕11年的实用干货。' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: 'https://1814.love/blog' },
|
||||
],
|
||||
})
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '旅行攻略' },
|
||||
])
|
||||
|
||||
useScrollReveal()
|
||||
|
||||
const categories = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: '攻略', label: '出行攻略' },
|
||||
{ key: '亲子', label: '亲子游' },
|
||||
{ key: '避坑', label: '避坑指南' },
|
||||
{ key: '穿搭', label: '旅拍穿搭' },
|
||||
]
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const allArticles = computed(() => blogData.value?.articles || [])
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
if (activeCategory.value === 'all') return allArticles.value
|
||||
return allArticles.value.filter(a => a.category === activeCategory.value)
|
||||
})
|
||||
|
||||
function getCategoryCount(key) {
|
||||
if (key === 'all') return allArticles.value.length
|
||||
return allArticles.value.filter(a => a.category === key).length
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 分类筛选
|
||||
.blog-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-2xl;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-sm @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
cursor: pointer;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: @color-primary;
|
||||
border-color: @color-primary;
|
||||
color: #fff;
|
||||
|
||||
.filter-count {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// 文章网格
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: 1fr 1fr;
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
});
|
||||
}
|
||||
|
||||
// 文章卡片
|
||||
.blog-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-lg;
|
||||
transform: translateY(-2px);
|
||||
|
||||
.blog-card__img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform @transition-slow;
|
||||
}
|
||||
|
||||
&__category {
|
||||
position: absolute;
|
||||
top: @space-sm;
|
||||
left: @space-sm;
|
||||
padding: 3px @space-sm;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: @space-lg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
line-height: @line-height-tight;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-accent;
|
||||
margin: 0;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
.text-clamp(3);
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-xs;
|
||||
margin-top: auto;
|
||||
padding-top: @space-sm;
|
||||
}
|
||||
|
||||
&__read-more {
|
||||
display: inline-block;
|
||||
margin-top: @space-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
transition: color @transition-fast;
|
||||
|
||||
.blog-card:hover & {
|
||||
color: @color-primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
display: inline-block;
|
||||
padding: 2px @space-sm;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.blog-empty {
|
||||
text-align: center;
|
||||
padding: @space-3xl;
|
||||
color: @color-text-muted;
|
||||
font-size: @font-size-md;
|
||||
}
|
||||
|
||||
// CTA
|
||||
.blog-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;
|
||||
max-width: 500px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.blog-cta-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: @space-xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
+663
@@ -0,0 +1,663 @@
|
||||
<template>
|
||||
<div class="page-calendar">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="呼伦贝尔什么时候去最好?基于11年带队数据,为您提供每月详细出行参考"
|
||||
:breadcrumbs="[{ text: '行程日历' }]"
|
||||
background-image="/images/seasons/autumn-lake.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="行程日历摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔逐月出行指南:基于11年带队数据,详解1-12月天气、景色、适合活动和推荐行程。帮你选择最佳出行时间。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="calData" class="section">
|
||||
<div class="container">
|
||||
|
||||
<!-- Quick recommendation summary -->
|
||||
<div class="cal-summary">
|
||||
<h2 class="cal-summary-title">呼伦贝尔最佳出行时间一览</h2>
|
||||
<div class="summary-grid">
|
||||
<div
|
||||
v-for="rec in calData.meta.quickRecommendations"
|
||||
:key="rec.label"
|
||||
class="summary-card"
|
||||
>
|
||||
<div class="summary-label">{{ rec.label }}</div>
|
||||
<div class="summary-months">{{ rec.months }}</div>
|
||||
<div class="summary-reason">{{ rec.reason }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 出行时段建议 -->
|
||||
<div class="advice-section">
|
||||
<h2 class="advice-title">什么时候来呼伦贝尔?</h2>
|
||||
<p class="advice-subtitle">来呼伦贝尔一趟还是比较折腾的,希望大家能在每个季节相对最美的时候过来</p>
|
||||
<div class="advice-timeline">
|
||||
<div
|
||||
v-for="item in calData.meta.travelAdvice"
|
||||
:key="item.period"
|
||||
:class="['advice-item', `advice-item--${item.level}`]"
|
||||
>
|
||||
<div class="advice-badge">{{ item.levelText }}</div>
|
||||
<div class="advice-content">
|
||||
<h3 class="advice-period">{{ item.period }}</h3>
|
||||
<p class="advice-desc">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Month grid -->
|
||||
<h2 class="months-heading">逐月出行指南</h2>
|
||||
<div class="months-grid">
|
||||
<div
|
||||
v-for="m in calData.months"
|
||||
:key="m.month"
|
||||
:class="['month-card', `month-card--${m.season}`, { 'month-card--peak': m.isPeakSeason }]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="month-header">
|
||||
<div class="month-name-area">
|
||||
<span class="month-number">{{ m.month }}</span>
|
||||
<span class="month-unit">月</span>
|
||||
<span class="month-name">{{ m.name }}</span>
|
||||
</div>
|
||||
<span v-if="m.isPeakSeason" class="peak-badge">旺季</span>
|
||||
</div>
|
||||
|
||||
<!-- Star rating -->
|
||||
<div class="month-stars" :aria-label="`推荐指数 ${m.ratingStars} 星`">
|
||||
<span
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
:class="['star', { 'star--active': i <= m.ratingStars }]"
|
||||
aria-hidden="true"
|
||||
>★</span>
|
||||
<span class="stars-label">推荐指数</span>
|
||||
</div>
|
||||
|
||||
<!-- Temperature -->
|
||||
<div class="month-temp">{{ m.tempRange }}</div>
|
||||
|
||||
<!-- Weather -->
|
||||
<p class="month-weather">{{ m.weatherFeature }}</p>
|
||||
|
||||
<!-- Highlights -->
|
||||
<ul class="month-highlights">
|
||||
<li v-for="(h, i) in m.highlights.slice(0, 3)" :key="i">{{ h }}</li>
|
||||
</ul>
|
||||
|
||||
<!-- Clothing -->
|
||||
<div class="month-clothing">
|
||||
<span class="clothing-label">穿搭</span>
|
||||
<span class="clothing-tip">{{ m.clothingTips }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Recommended products -->
|
||||
<div v-if="m.recommendedProducts.length" class="month-products">
|
||||
<span
|
||||
v-for="prod in m.recommendedProducts"
|
||||
:key="prod"
|
||||
class="product-tag"
|
||||
>{{ prod }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tagline -->
|
||||
<p class="month-tagline">{{ m.tagline }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEO text answer section -->
|
||||
<div class="cal-answer">
|
||||
<h2>呼伦贝尔几月份去最好?详细解答</h2>
|
||||
<p>
|
||||
呼伦贝尔全年皆可出行,但来一趟毕竟不轻松,建议选对时间再出发。<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="咨询最佳出行方案" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: calData } = await usePublicApi('calendar')
|
||||
|
||||
const seo = useSEO('calendar')
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '行程日历' },
|
||||
])
|
||||
|
||||
// TouristDestination JSON-LD
|
||||
useHead({
|
||||
script: [{
|
||||
key: 'tourist-destination-hulunbuir',
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TouristDestination',
|
||||
name: '呼伦贝尔大草原',
|
||||
description: '呼伦贝尔是中国最美草原之一,位于内蒙古自治区东北部,拥有莫日格勒河、额尔古纳湿地、白桦林、呼伦湖等核心景观。最佳旅游时节为6-9月,夏季草原翠绿,秋季白桦金黄。',
|
||||
url: 'https://1814.love/calendar',
|
||||
image: 'https://1814.love/images/team/hero-grassland.jpg',
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: 49.2143,
|
||||
longitude: 119.7674,
|
||||
},
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: '呼伦贝尔市',
|
||||
addressRegion: '内蒙古自治区',
|
||||
addressCountry: 'CN',
|
||||
},
|
||||
touristType: ['家庭游客', '亲子家庭', '摄影爱好者', '自然爱好者'],
|
||||
includesAttraction: [
|
||||
{ '@type': 'TouristAttraction', name: '莫日格勒河(额吉的故乡)', description: '呼伦贝尔最美河湾,夏季翠绿如画,是呼籁旅行夏季核心景点' },
|
||||
{ '@type': 'TouristAttraction', name: '额尔古纳湿地', description: '中国最大湿地之一,鸟类天堂,秋季倒影绝美' },
|
||||
{ '@type': 'TouristAttraction', name: '白桦林', description: '九月金黄,摄影圣地,游牧的森林秋季产品核心景点' },
|
||||
{ '@type': 'TouristAttraction', name: '呼伦湖', description: '中国北方第一大湖,冬季冰钓胜地,夏季湖畔骑马' },
|
||||
{ '@type': 'TouristAttraction', name: '中俄边境公路', description: '室韦至黑山头沿线,风景壮阔,国境线骑行体验独特' },
|
||||
],
|
||||
}),
|
||||
}],
|
||||
})
|
||||
|
||||
// Seasonal Event JSON-LD
|
||||
const seasonalEvents = [
|
||||
{
|
||||
key: 'event-summer',
|
||||
name: '呼伦贝尔夏季旅游最佳期(6-8月)',
|
||||
description: '呼伦贝尔草原夏季旅游高峰,绿草如茵,那达慕传统节庆,骑马、露营、亲子出行最佳时段。呼籁旅行提供额吉的故乡V9系列定制游及小蒙马亲子夏令营。',
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-08-31',
|
||||
},
|
||||
{
|
||||
key: 'event-autumn',
|
||||
name: '呼伦贝尔秋季白桦林观赏期(9月)',
|
||||
description: '呼伦贝尔草原秋季白桦林金黄,草原转秋色,是摄影爱好者公认的好时节。呼籁旅行提供游牧的森林秋季定制游产品。',
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2026-09-30',
|
||||
},
|
||||
{
|
||||
key: 'event-winter',
|
||||
name: '呼伦贝尔冬季冰雪体验期(12-2月)',
|
||||
description: '呼伦贝尔冬季冰雪奇观,冰雪那达慕、冰钓、驯鹿部落探访,零下30度的极寒体验。呼籁旅行提供嗨冰雪及小蒙马冬季营产品。',
|
||||
startDate: '2026-12-01',
|
||||
endDate: '2027-02-28',
|
||||
},
|
||||
]
|
||||
|
||||
seasonalEvents.forEach((event) => {
|
||||
useHead({
|
||||
script: [{
|
||||
key: event.key,
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: '呼伦贝尔大草原',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: '呼伦贝尔市',
|
||||
addressRegion: '内蒙古自治区',
|
||||
addressCountry: 'CN',
|
||||
},
|
||||
geo: { '@type': 'GeoCoordinates', latitude: 49.2143, longitude: 119.7674 },
|
||||
},
|
||||
organizer: { '@id': 'https://1814.love/#organization' },
|
||||
eventStatus: 'https://schema.org/EventScheduled',
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
url: 'https://1814.love/calendar',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
priceCurrency: 'CNY',
|
||||
availability: 'https://schema.org/InStock',
|
||||
seller: { '@id': 'https://1814.love/#organization' },
|
||||
},
|
||||
}),
|
||||
}],
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// Summary section
|
||||
.cal-summary {
|
||||
margin-bottom: @space-3xl;
|
||||
}
|
||||
|
||||
.cal-summary-title {
|
||||
font-size: @font-size-xl;
|
||||
text-align: center;
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-top: 3px solid @color-primary;
|
||||
border-radius: @border-radius-md;
|
||||
padding: @space-lg;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
font-weight: @font-weight-medium;
|
||||
margin-bottom: @space-xs;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summary-months {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
.summary-reason {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
// 出行时段建议
|
||||
.advice-section {
|
||||
margin-bottom: @space-3xl;
|
||||
}
|
||||
|
||||
.advice-title {
|
||||
font-size: @font-size-xl;
|
||||
color: @color-text-primary;
|
||||
text-align: center;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
.advice-subtitle {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
text-align: center;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.advice-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.advice-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: @space-md;
|
||||
padding: @space-md @space-lg;
|
||||
border-radius: @border-radius-md;
|
||||
border: 1px solid @color-divider;
|
||||
background: @color-bg-white;
|
||||
transition: box-shadow @transition-fast;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-sm;
|
||||
}
|
||||
|
||||
&--best_value {
|
||||
background: @color-primary-bg;
|
||||
border-color: rgba(45, 106, 79, 0.25);
|
||||
|
||||
.advice-badge {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--highly_recommended {
|
||||
background: #FFF8F0;
|
||||
border-color: rgba(212, 136, 58, 0.25);
|
||||
|
||||
.advice-badge {
|
||||
background: @color-accent;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--peak {
|
||||
background: @color-primary-bg;
|
||||
border-color: rgba(45, 106, 79, 0.2);
|
||||
|
||||
.advice-badge {
|
||||
background: @color-primary-light;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--recommended {
|
||||
.advice-badge {
|
||||
background: #EFF4F8;
|
||||
color: #5B8DB8;
|
||||
}
|
||||
}
|
||||
|
||||
&--ok {
|
||||
.advice-badge {
|
||||
background: @color-bg-gray;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&--not_recommended {
|
||||
opacity: 0.65;
|
||||
|
||||
.advice-badge {
|
||||
background: @color-bg-gray;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advice-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 12px;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.advice-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.advice-period {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.advice-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Month heading
|
||||
.months-heading {
|
||||
font-size: @font-size-xl;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-xl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Month grid
|
||||
.months-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-3xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
// Month card
|
||||
.month-card {
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-lg;
|
||||
border: 1px solid @color-border;
|
||||
|
||||
&--summer {
|
||||
background: @color-primary-bg;
|
||||
border-color: rgba(45, 106, 79, 0.2);
|
||||
}
|
||||
|
||||
&--autumn {
|
||||
background: #FFF8F0;
|
||||
border-color: rgba(212, 136, 58, 0.2);
|
||||
}
|
||||
|
||||
&--winter {
|
||||
background: #EFF4F8;
|
||||
border-color: rgba(91, 141, 184, 0.2);
|
||||
}
|
||||
|
||||
&--spring {
|
||||
background: #F4FAF4;
|
||||
border-color: rgba(82, 163, 106, 0.2);
|
||||
}
|
||||
|
||||
&--peak {
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.month-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.month-name-area {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.month-number {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.month-unit {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
.month-name {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin-left: @space-xs;
|
||||
}
|
||||
|
||||
.peak-badge {
|
||||
display: inline-block;
|
||||
padding: 2px @space-sm;
|
||||
background: @color-accent;
|
||||
color: white;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
|
||||
.month-stars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.star {
|
||||
font-size: 14px;
|
||||
color: @color-border;
|
||||
|
||||
&--active {
|
||||
color: @color-accent;
|
||||
}
|
||||
}
|
||||
|
||||
.stars-label {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
margin-left: @space-xs;
|
||||
}
|
||||
|
||||
.month-temp {
|
||||
display: inline-block;
|
||||
padding: 2px @space-sm;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.month-weather {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.month-highlights {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 @space-md;
|
||||
|
||||
li {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
padding-left: @space-md;
|
||||
position: relative;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month-clothing {
|
||||
display: flex;
|
||||
gap: @space-sm;
|
||||
align-items: flex-start;
|
||||
margin-bottom: @space-md;
|
||||
padding: @space-sm @space-md;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: @border-radius-sm;
|
||||
}
|
||||
|
||||
.clothing-label {
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.clothing-tip {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
.month-products {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-xs;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.product-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: @color-primary;
|
||||
color: white;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.month-tagline {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding-top: @space-sm;
|
||||
}
|
||||
|
||||
// SEO answer section
|
||||
.cal-answer {
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-2xl;
|
||||
|
||||
h2 {
|
||||
font-size: @font-size-xl;
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
strong {
|
||||
color: @color-text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cal-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-lg;
|
||||
}
|
||||
</style>
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div v-if="contact" class="page-contact">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="选择最方便的方式联系我们"
|
||||
:breadcrumbs="[{ text: '联系我们' }]"
|
||||
background-image="/images/summer-camp/grassland-group.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="联系我们摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>联系呼籁旅行:微信扫码加客服,或拨打电话咨询。定制师工作日2小时内回复,节假日24小时内回复。预订旺季行程(7月)建议提前2-3个月联系。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主要联系方式:微信 + 电话 -->
|
||||
<section class="section reveal" aria-label="主要联系方式">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="立即联系" subtitle="微信或电话,开启你的呼伦贝尔之旅" />
|
||||
<div class="contact-primary">
|
||||
<!-- 微信客服 -->
|
||||
<div class="contact-card contact-card--wechat">
|
||||
<div class="contact-card__badge">推荐方式</div>
|
||||
<div class="contact-card__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="32" height="32">
|
||||
<path d="M8.5 10.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM14.5 10.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" fill="currentColor" stroke="none"/>
|
||||
<path d="M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 0 1-4.255-.96L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="contact-card__title">微信客服</h2>
|
||||
<p class="contact-card__desc">添加微信,定制师一对一沟通行程需求</p>
|
||||
<CommonQRCodeBlock
|
||||
v-if="wechatChannel"
|
||||
:image="wechatChannel.qrImage"
|
||||
label="扫码添加微信"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 电话咨询 -->
|
||||
<div class="contact-card contact-card--phone" v-if="phoneChannel">
|
||||
<div class="contact-card__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="32" height="32">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="contact-card__title">电话咨询</h2>
|
||||
<p class="contact-card__phone-number">{{ phoneChannel.value }}</p>
|
||||
<p class="contact-card__desc">{{ phoneChannel.description }}</p>
|
||||
<a :href="`tel:${phoneChannel.value}`" class="contact-card__action">
|
||||
拨打电话
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 其他联系方式 -->
|
||||
<section class="section section--gray reveal" aria-label="更多联系方式">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="更多联系方式" subtitle="商务合作、投诉建议、加入团队" />
|
||||
<div class="contact-grid">
|
||||
<div v-for="ch in secondaryChannels" :key="ch.type" class="contact-grid-item">
|
||||
<div class="contact-grid-item__icon">
|
||||
<svg v-if="ch.type === 'address'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="24" height="24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<svg v-else-if="ch.type === 'email'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="24" height="24"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
<svg v-else-if="ch.type === 'complaint'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="24" height="24"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/><path d="M12 8v4M12 16h.01"/></svg>
|
||||
<svg v-else-if="ch.type === 'hr'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="24" height="24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="24" height="24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
|
||||
</div>
|
||||
<div class="contact-grid-item__content">
|
||||
<h3 class="contact-grid-item__label">{{ ch.label }}</h3>
|
||||
<p class="contact-grid-item__value">{{ ch.value }}</p>
|
||||
<p v-if="ch.description" class="contact-grid-item__desc">{{ ch.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<section class="section reveal" aria-label="安全提示">
|
||||
<div class="container">
|
||||
<div class="contact-security">
|
||||
<div class="contact-security__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="28" height="28"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</div>
|
||||
<div class="contact-security__content">
|
||||
<h3 class="contact-security__title">官方渠道保障</h3>
|
||||
<p class="contact-security__text">{{ contact.securityNotice }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 长辈定制提示 -->
|
||||
<section class="section section--warm reveal" aria-label="长辈定制">
|
||||
<div class="container">
|
||||
<div class="contact-elder">
|
||||
<div class="contact-elder__badge">牵手草原</div>
|
||||
<h3 class="contact-elder__title">给爸妈安排一趟草原之旅</h3>
|
||||
<p class="contact-elder__desc">联系定制师时告知「牵手草原」长辈行程,我们会根据长辈的身体状况和偏好,定制节奏更缓、住宿更舒适的专属方案。也支持爸妈和朋友一起组团出发。</p>
|
||||
<NuxtLink to="/contact" class="contact-elder__action" @click.prevent="scrollToTop">
|
||||
立即咨询长辈行程
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内链闭环 -->
|
||||
<CommonSectionCTA
|
||||
title="还有疑问?"
|
||||
description="查看常见问题的详细解答"
|
||||
:links="[{ to: '/faq', text: '查看呼伦贝尔亲子游常见问答' }]"
|
||||
bg="gray"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOrganizationSchema, useBreadcrumbSchema, useContactPageSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
|
||||
const visibleChannels = computed(() => (contact.value?.channels || []).filter(ch => !ch.hidden))
|
||||
const wechatChannel = computed(() => visibleChannels.value.find(ch => ch.type === 'wechat'))
|
||||
const phoneChannel = computed(() => visibleChannels.value.find(ch => ch.type === 'phone'))
|
||||
const secondaryChannels = computed(() => visibleChannels.value.filter(ch => ch.type !== 'wechat' && ch.type !== 'phone'))
|
||||
|
||||
const seo = useSEO('contact')
|
||||
useScrollReveal()
|
||||
useOrganizationSchema(brand.value, contact.value)
|
||||
useContactPageSchema(brand.value, contact.value)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '联系我们' },
|
||||
])
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 主要联系方式 =====
|
||||
.contact-primary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
max-width: 800px;
|
||||
margin: @space-xl auto 0;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: @space-2xl @space-xl;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-xl;
|
||||
box-shadow: @shadow-xs;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-lg;
|
||||
transform: translateY(-4px);
|
||||
border-color: @color-primary-lighter;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: @space-xs @space-lg;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, @color-primary-dark, @color-primary);
|
||||
border-radius: @border-radius-full;
|
||||
letter-spacing: 2px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(45, 106, 79, 0.3);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: fade(@color-primary, 8%);
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-lg;
|
||||
transition: background @transition-base;
|
||||
}
|
||||
|
||||
&:hover &__icon {
|
||||
background: fade(@color-primary, 15%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin-bottom: @space-lg;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
&__phone-number {
|
||||
font-size: @font-size-3xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: @space-sm;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-sm @space-xl;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
border: 2px solid @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
margin-top: @space-md;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--phone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 联系方式网格 =====
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
max-width: 900px;
|
||||
margin: @space-xl auto 0;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.contact-grid-item {
|
||||
display: flex;
|
||||
gap: @space-lg;
|
||||
align-items: flex-start;
|
||||
padding: @space-xl;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-2px);
|
||||
border-color: @color-primary-lighter;
|
||||
|
||||
.contact-grid-item__icon {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: @border-radius-md;
|
||||
background: fade(@color-primary, 8%);
|
||||
color: @color-primary;
|
||||
transition: all @transition-base;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
word-break: break-all;
|
||||
margin-bottom: 4px;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 安全提示 =====
|
||||
.contact-security {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: @space-lg;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: @space-xl @space-2xl;
|
||||
background: linear-gradient(135deg, fade(@color-primary, 5%), fade(@color-primary, 10%));
|
||||
border: 1px solid fade(@color-primary, 15%);
|
||||
border-radius: @border-radius-xl;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: @space-xl;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary-dark;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 长辈定制 =====
|
||||
.contact-elder {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: @space-xs @space-lg;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-accent;
|
||||
background: fade(@color-accent, 12%);
|
||||
border-radius: @border-radius-full;
|
||||
letter-spacing: 3px;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-md;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-md @space-2xl;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, @color-accent, darken(@color-accent, 8%));
|
||||
border-radius: @border-radius-full;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
box-shadow: 0 4px 14px rgba(212, 136, 58, 0.3);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(212, 136, 58, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+769
@@ -0,0 +1,769 @@
|
||||
<template>
|
||||
<div class="page-courses">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="小蒙马亲子研学·6大课程模块·3-12岁分龄设计"
|
||||
:breadcrumbs="[{ text: '小蒙马夏令营', to: '/summer-camp' }, { text: '研学课程体系' }]"
|
||||
background-image="/images/summer-camp/birch-group.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="研学课程摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>小蒙马亲子研学课程体系:6大课程模块,3-12岁分龄设计。融合草原生态、蒙古文化、艺术创作、户外探索等主题,寓教于乐。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 教育理念 -->
|
||||
<section v-if="courses" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<div class="philosophy-banner">
|
||||
<div class="philosophy-text">
|
||||
<h2 class="philosophy-headline">{{ courses.philosophy.headline }}</h2>
|
||||
<p class="philosophy-tagline">{{ courses.philosophy.tagline }}</p>
|
||||
<p class="philosophy-desc">{{ courses.philosophy.description }}</p>
|
||||
</div>
|
||||
<div class="philosophy-values">
|
||||
<div
|
||||
v-for="v in courses.philosophy.values"
|
||||
:key="v.label"
|
||||
class="philosophy-value-item"
|
||||
>
|
||||
<span class="philosophy-value-label">{{ v.label }}</span>
|
||||
<p class="philosophy-value-desc">{{ v.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 年龄分层选择器 -->
|
||||
<section v-if="courses" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="按年龄找适合的课程"
|
||||
subtitle="不同年龄段,有不同的学习目标和课程深度"
|
||||
/>
|
||||
<div class="age-tabs">
|
||||
<button
|
||||
v-for="group in courses.ageGroups"
|
||||
:key="group.id"
|
||||
class="age-tab"
|
||||
:class="{ 'age-tab--active': activeGroup === group.id }"
|
||||
@click="activeGroup = group.id"
|
||||
>
|
||||
<span class="age-tab-emoji">{{ group.emoji }}</span>
|
||||
<span class="age-tab-label">{{ group.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeGroupData" class="age-content">
|
||||
<div class="age-content-header">
|
||||
<h3 class="age-content-headline">{{ activeGroupData.headline }}</h3>
|
||||
<span class="age-focus-badge">{{ activeGroupData.focus }}</span>
|
||||
</div>
|
||||
<p class="age-content-desc">{{ activeGroupData.description }}</p>
|
||||
<div class="age-content-body">
|
||||
<div class="age-highlights">
|
||||
<h4 class="age-highlights-title">这个年龄段能做什么</h4>
|
||||
<ul class="age-highlights-list">
|
||||
<li v-for="h in activeGroupData.highlights" :key="h" class="age-highlight-item">
|
||||
{{ h }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="age-modules">
|
||||
<h4 class="age-modules-title">推荐课程模块</h4>
|
||||
<div class="age-module-tags">
|
||||
<span
|
||||
v-for="mod in activeGroupModules"
|
||||
:key="mod.id"
|
||||
class="age-module-tag"
|
||||
>
|
||||
{{ mod.icon }} {{ mod.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 中间 CTA -->
|
||||
<CommonSectionCTA
|
||||
title="想了解小蒙马夏令营详情?"
|
||||
description="7天6晚亲子研学,每期限8组家庭,现在咨询了解营期安排"
|
||||
:links="[
|
||||
{ to: '/summer-camp', text: '查看小蒙马夏令营' },
|
||||
{ to: '/contact', text: '咨询报名' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 六大课程模块 -->
|
||||
<section v-if="courses" class="section section--gray reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="6大课程模块"
|
||||
subtitle="每个模块有明确的学习目标、活动设计和教育理念"
|
||||
/>
|
||||
<div class="modules-grid">
|
||||
<div
|
||||
v-for="mod in courses.modules"
|
||||
:key="mod.id"
|
||||
class="module-card"
|
||||
:class="{ 'module-card--expanded': expandedModules[mod.id] }"
|
||||
>
|
||||
<button class="module-card-header" @click="toggleModule(mod.id)">
|
||||
<div class="module-card-title-row">
|
||||
<span class="module-icon">{{ mod.icon }}</span>
|
||||
<div class="module-title-group">
|
||||
<h3 class="module-name">{{ mod.name }}</h3>
|
||||
<div class="module-meta">
|
||||
<span class="module-meta-item">{{ mod.ageRange }}</span>
|
||||
<span class="module-meta-sep">·</span>
|
||||
<span class="module-meta-item">{{ mod.duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="module-short-desc">{{ mod.shortDesc }}</p>
|
||||
<span class="module-toggle">{{ expandedModules[mod.id] ? '收起' : '展开详情' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-show="expandedModules[mod.id]" class="module-card-body">
|
||||
<div class="module-section">
|
||||
<h4 class="module-section-title">学习目标</h4>
|
||||
<ul class="module-list">
|
||||
<li v-for="obj in mod.objectives" :key="obj">{{ obj }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="module-section">
|
||||
<h4 class="module-section-title">活动内容</h4>
|
||||
<ul class="module-list">
|
||||
<li v-for="act in mod.activities" :key="act">{{ act }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<blockquote class="module-philosophy">
|
||||
{{ mod.philosophy }}
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 师资介绍(占位) -->
|
||||
<section v-if="courses" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="课程导师团队"
|
||||
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>
|
||||
<div class="instructor-info">
|
||||
<p class="instructor-name">{{ inst.name }}</p>
|
||||
<p class="instructor-title">{{ inst.title }}</p>
|
||||
<p class="instructor-cred">{{ inst.credentials }}</p>
|
||||
<p class="instructor-bio">{{ inst.bio }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 家长常见问题 -->
|
||||
<section v-if="courses" class="section section--warm">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="家长常见问题" />
|
||||
<div class="courses-faq">
|
||||
<FaqItem
|
||||
v-for="(item, i) in courses.faq"
|
||||
:key="item.question"
|
||||
:question="item.question"
|
||||
:answer="item.answer"
|
||||
:initial-open="i === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部 CTA -->
|
||||
<CommonSectionCTA
|
||||
title="准备好让孩子在草原上上课了吗?"
|
||||
description="报名小蒙马夏令营,或联系我们了解定制研学方案"
|
||||
:links="[
|
||||
{ to: '/summer-camp', text: '小蒙马夏令营详情' },
|
||||
{ to: '/customize', text: '定制研学方案' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: courses } = await usePublicApi('courses')
|
||||
|
||||
const seo = useSEO('courses')
|
||||
useScrollReveal()
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '小蒙马夏令营', to: '/summer-camp' },
|
||||
{ text: '研学课程体系' },
|
||||
])
|
||||
|
||||
// JSON-LD: EducationalOrganization + Course schemas
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'EducationalOrganization',
|
||||
name: '小蒙马研学·呼籁旅行',
|
||||
url: 'https://1814.love/courses',
|
||||
description: '小蒙马研学是呼籁旅行旗下亲子研学品牌,以呼伦贝尔草原为天然课堂,提供3-12岁分龄自然教育课程体系,含草原生态、马术体验、游牧文化、自然艺术、团队成长、LNT环保六大模块。',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: '呼伦贝尔市',
|
||||
addressRegion: '内蒙古自治区',
|
||||
addressCountry: 'CN',
|
||||
},
|
||||
parentOrganization: {
|
||||
'@type': 'Organization',
|
||||
name: '呼籁旅行',
|
||||
url: 'https://1814.love',
|
||||
},
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: '小蒙马研学课程体系',
|
||||
itemListElement: (courses.value?.modules || []).map((mod, i) => ({
|
||||
'@type': 'Course',
|
||||
position: i + 1,
|
||||
name: mod.name,
|
||||
description: mod.shortDesc,
|
||||
provider: {
|
||||
},
|
||||
hasCourseInstance: {
|
||||
'@type': 'CourseInstance',
|
||||
courseMode: 'onsite',
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: '呼伦贝尔草原',
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 年龄分层选择
|
||||
const activeGroup = ref('child')
|
||||
const activeGroupData = computed(() => (courses.value?.ageGroups || []).find((g) => g.id === activeGroup.value))
|
||||
const activeGroupModules = computed(() =>
|
||||
activeGroupData.value
|
||||
? (courses.value?.modules || []).filter((m) => activeGroupData.value.recommendedModules.includes(m.id))
|
||||
: [],
|
||||
)
|
||||
|
||||
// 模块展开/收起
|
||||
const expandedModules = ref({})
|
||||
function toggleModule(id) {
|
||||
expandedModules.value = { ...expandedModules.value, [id]: !expandedModules.value[id] }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 教育理念 =====
|
||||
.philosophy-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-2xl;
|
||||
|
||||
.respond-lg({
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: @space-3xl;
|
||||
});
|
||||
}
|
||||
|
||||
.philosophy-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.philosophy-headline {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.philosophy-tagline {
|
||||
font-size: @font-size-lg;
|
||||
color: @color-text-secondary;
|
||||
font-weight: @font-weight-medium;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.philosophy-desc {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.philosophy-values {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.philosophy-value-item {
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
transition: box-shadow @transition-fast;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.philosophy-value-label {
|
||||
display: inline-block;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
padding: @space-xs @space-sm;
|
||||
border-radius: @border-radius-full;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.philosophy-value-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// ===== 年龄分层 =====
|
||||
.age-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
flex-direction: row;
|
||||
gap: @space-md;
|
||||
});
|
||||
}
|
||||
|
||||
.age-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
padding: @space-md @space-lg;
|
||||
border: 2px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
background: @color-bg-white;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color @transition-fast, background @transition-fast, box-shadow @transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary-lighter;
|
||||
box-shadow: @shadow-sm;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.age-tab-emoji {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.age-tab-label {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
|
||||
.age-tab--active & {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.age-content {
|
||||
padding: @space-xl;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
}
|
||||
|
||||
.age-content-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.age-content-headline {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.age-focus-badge {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-accent;
|
||||
background: fade(@color-accent, 12%);
|
||||
padding: @space-xs @space-md;
|
||||
border-radius: @border-radius-full;
|
||||
font-weight: @font-weight-medium;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.age-content-desc {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.age-content-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: 3fr 2fr;
|
||||
});
|
||||
}
|
||||
|
||||
.age-highlights-title,
|
||||
.age-modules-title {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-md;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.age-highlights-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.age-highlight-item {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
padding-left: @space-lg;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.age-module-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.age-module-tag {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
border: 1px solid fadeout(@color-primary, 70%);
|
||||
padding: @space-xs @space-md;
|
||||
border-radius: @border-radius-full;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// ===== 课程模块 =====
|
||||
.modules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.module-card {
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
overflow: hidden;
|
||||
transition: border-color @transition-fast, box-shadow @transition-fast;
|
||||
|
||||
&:hover,
|
||||
&--expanded {
|
||||
border-color: @color-primary-lighter;
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.module-card-header {
|
||||
width: 100%;
|
||||
padding: @space-xl;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.module-card-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.module-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.module-title-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-xs;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
.module-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.module-meta-item {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.module-meta-sep {
|
||||
color: @color-text-muted;
|
||||
font-size: @font-size-xs;
|
||||
}
|
||||
|
||||
.module-short-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.module-toggle {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.module-card-body {
|
||||
padding: 0 @space-xl @space-xl;
|
||||
border-top: 1px solid @color-border;
|
||||
padding-top: @space-lg;
|
||||
}
|
||||
|
||||
.module-section {
|
||||
margin-bottom: @space-lg;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.module-section-title {
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.module-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
|
||||
li {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
padding-left: @space-md;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: @color-primary;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.module-philosophy {
|
||||
margin: 0;
|
||||
padding: @space-md @space-lg;
|
||||
background: @color-primary-bg;
|
||||
border-left: 3px solid @color-primary;
|
||||
border-radius: 0 @border-radius-md @border-radius-md 0;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ===== 师资占位 =====
|
||||
.instructor-placeholder-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: @space-md;
|
||||
padding: @space-lg @space-xl;
|
||||
background: #fff8e6;
|
||||
border: 1px solid #f0c060;
|
||||
border-radius: @border-radius-lg;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.instructor-placeholder-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.instructor-placeholder-title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: #a06000;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
|
||||
.instructor-placeholder-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: #7a5000;
|
||||
margin: 0;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
.instructor-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.instructor-card {
|
||||
display: flex;
|
||||
gap: @space-lg;
|
||||
padding: @space-xl;
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px dashed @color-border;
|
||||
}
|
||||
|
||||
.instructor-avatar-placeholder {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: @color-border;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.instructor-name {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-secondary;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
|
||||
.instructor-title {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
|
||||
.instructor-cred,
|
||||
.instructor-bio {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0 0 @space-xs;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
// ===== FAQ =====
|
||||
.courses-faq {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
</style>
|
||||
+995
@@ -0,0 +1,995 @@
|
||||
<template>
|
||||
<div class="page-customize">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="填写出行需求,定制师2小时内回复,免费咨询"
|
||||
:breadcrumbs="[{ text: '在线定制' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="在线定制摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>在线提交定制需求:填写出行人数、时间、偏好,定制师2小时内免费回复专属方案。已服务10000+组家庭,支持微信/电话/表单多渠道咨询。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 信任指标条 -->
|
||||
<section v-if="customize" class="trust-bar">
|
||||
<div class="container">
|
||||
<ul class="trust-list">
|
||||
<li v-for="stat in customize.trustStats" :key="stat.label" class="trust-item">
|
||||
<span class="trust-value">{{ stat.value }}<em class="trust-unit">{{ stat.unit }}</em></span>
|
||||
<span class="trust-label">{{ stat.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 表单主区域 -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="customize-layout">
|
||||
|
||||
<!-- 表单主体 -->
|
||||
<div class="form-wrap">
|
||||
|
||||
<!-- 提交成功状态 -->
|
||||
<div v-if="submitted" class="success-card">
|
||||
<div class="success-check">✓</div>
|
||||
<h2 class="success-title">需求已收到!</h2>
|
||||
<p class="success-desc">
|
||||
定制师将在 <strong>2小时内</strong> 通过微信与您联系,<br>
|
||||
请保持手机畅通。
|
||||
</p>
|
||||
<div class="success-wechat">
|
||||
<span class="wechat-label">也可主动添加定制师微信</span>
|
||||
<span class="wechat-id">{{ customize.contact.wechat }}</span>
|
||||
</div>
|
||||
<p class="success-tip">{{ customize.contact.tip }}</p>
|
||||
<button class="btn-reset" @click="resetForm">重新填写</button>
|
||||
</div>
|
||||
|
||||
<!-- 定制表单 -->
|
||||
<form v-else class="customize-form" novalidate @submit.prevent="handleSubmit">
|
||||
|
||||
<!-- 1. 出行人数 -->
|
||||
<fieldset class="form-section">
|
||||
<legend class="section-legend">
|
||||
<span class="legend-num">1</span>出行人数
|
||||
</legend>
|
||||
<div class="people-row">
|
||||
<div class="form-field">
|
||||
<label for="adults">成人</label>
|
||||
<div class="number-ctrl">
|
||||
<button type="button" aria-label="减少成人数" @click="decrement('adults', 1)">−</button>
|
||||
<span class="number-display" aria-live="polite">{{ form.adults }}</span>
|
||||
<button type="button" aria-label="增加成人数" @click="increment('adults', 20)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="children">儿童</label>
|
||||
<div class="number-ctrl">
|
||||
<button type="button" aria-label="减少儿童数" @click="decrement('children', 0)">−</button>
|
||||
<span class="number-display" aria-live="polite">{{ form.children }}</span>
|
||||
<button type="button" aria-label="增加儿童数" @click="increment('children', 10)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 儿童年龄 -->
|
||||
<div v-if="form.children > 0" class="children-ages">
|
||||
<p class="ages-hint">请填写每位儿童的年龄(岁)</p>
|
||||
<div class="ages-row">
|
||||
<div v-for="i in form.children" :key="i" class="age-field">
|
||||
<label :for="`child-age-${i}`">孩子 {{ i }}</label>
|
||||
<select :id="`child-age-${i}`" v-model="form.childAges[i - 1]">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="age in 18" :key="age" :value="age">{{ age }} 岁</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 2. 出行计划 -->
|
||||
<fieldset class="form-section">
|
||||
<legend class="section-legend">
|
||||
<span class="legend-num">2</span>出行计划
|
||||
</legend>
|
||||
<div class="two-col">
|
||||
<div class="form-field">
|
||||
<label for="departure-date">出行日期</label>
|
||||
<input
|
||||
id="departure-date"
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
:min="minDate"
|
||||
class="input-date"
|
||||
>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="duration">出行天数</label>
|
||||
<select id="duration" v-model="form.duration">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="d in customize.durations" :key="d.value" :value="d.value">
|
||||
{{ d.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 3. 特殊需求 -->
|
||||
<fieldset class="form-section">
|
||||
<legend class="section-legend">
|
||||
<span class="legend-num">3</span>特殊需求
|
||||
<span class="legend-hint">可多选</span>
|
||||
</legend>
|
||||
<div class="checkbox-grid">
|
||||
<label
|
||||
v-for="item in customize.activities"
|
||||
:key="item.value"
|
||||
class="checkbox-item"
|
||||
:class="{ 'is-checked': form.activities.includes(item.value) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="item.value"
|
||||
v-model="form.activities"
|
||||
>
|
||||
<span class="check-label">{{ item.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 4. 预算范围 -->
|
||||
<fieldset class="form-section">
|
||||
<legend class="section-legend">
|
||||
<span class="legend-num">4</span>预算范围
|
||||
<span class="legend-hint">总费用参考</span>
|
||||
</legend>
|
||||
<div class="radio-grid">
|
||||
<label
|
||||
v-for="b in customize.budgets"
|
||||
:key="b.value"
|
||||
class="radio-item"
|
||||
:class="{ 'is-checked': form.budget === b.value }"
|
||||
>
|
||||
<input type="radio" :value="b.value" v-model="form.budget">
|
||||
<span>{{ b.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 5. 联系方式 -->
|
||||
<fieldset class="form-section">
|
||||
<legend class="section-legend">
|
||||
<span class="legend-num">5</span>联系方式
|
||||
</legend>
|
||||
<div class="contact-fields">
|
||||
<div class="form-field" :class="{ 'has-error': errors.name }">
|
||||
<label for="contact-name">
|
||||
您的姓名 <em class="required">*</em>
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
v-model.trim="form.name"
|
||||
type="text"
|
||||
placeholder="请输入姓名"
|
||||
autocomplete="name"
|
||||
>
|
||||
<span v-if="errors.name" class="error-msg" role="alert">{{ errors.name }}</span>
|
||||
</div>
|
||||
<div class="form-field" :class="{ 'has-error': errors.phone }">
|
||||
<label for="contact-phone">
|
||||
手机号码 <em class="required">*</em>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone"
|
||||
v-model.trim="form.phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
autocomplete="tel"
|
||||
>
|
||||
<span v-if="errors.phone" class="error-msg" role="alert">{{ errors.phone }}</span>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="contact-wechat">微信号</label>
|
||||
<input
|
||||
id="contact-wechat"
|
||||
v-model.trim="form.wechat"
|
||||
type="text"
|
||||
placeholder="与手机号相同可不填"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 6. 补充说明 -->
|
||||
<fieldset class="form-section">
|
||||
<legend class="section-legend">
|
||||
<span class="legend-num">6</span>补充说明
|
||||
<span class="legend-hint">选填</span>
|
||||
</legend>
|
||||
<textarea
|
||||
v-model="form.remarks"
|
||||
rows="4"
|
||||
placeholder="其他特殊需求、出行偏好、想去的地方……"
|
||||
class="form-textarea"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="form-submit">
|
||||
<button type="submit" class="btn-submit">
|
||||
提交定制需求
|
||||
</button>
|
||||
<p class="submit-hint">提交后定制师2小时内联系您,免费咨询,无任何套路</p>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 侧边辅助信息 -->
|
||||
<aside class="customize-aside">
|
||||
<div class="aside-card">
|
||||
<h3 class="aside-title">定制服务流程</h3>
|
||||
<ol class="process-list">
|
||||
<li v-for="(step, i) in customize.process" :key="i" class="process-item">
|
||||
<span class="process-num">{{ i + 1 }}</span>
|
||||
<div class="process-text">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<p>{{ step.desc }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="aside-card aside-wechat">
|
||||
<h3 class="aside-title">不想填表?</h3>
|
||||
<p class="aside-desc">直接添加定制师微信,随时咨询:</p>
|
||||
<div class="wechat-id-display">{{ customize.contact.wechat }}</div>
|
||||
<p class="wechat-tip">{{ customize.contact.tip }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内链闭环 -->
|
||||
<CommonSectionCTA
|
||||
title="想先了解产品?"
|
||||
description="查看我们的行程版本,找到最适合您家庭的天数"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看4-7天行程版本' },
|
||||
{ to: '/faq', text: '查看常见问题解答' },
|
||||
]"
|
||||
bg="gray"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: customize } = await usePublicApi('customize')
|
||||
|
||||
const seo = useSEO('customize')
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '在线定制' },
|
||||
])
|
||||
|
||||
// 表单状态
|
||||
const form = reactive({
|
||||
adults: 2,
|
||||
children: 0,
|
||||
childAges: [],
|
||||
date: '',
|
||||
duration: '',
|
||||
activities: [],
|
||||
budget: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
remarks: '',
|
||||
})
|
||||
|
||||
const errors = reactive({ name: '', phone: '' })
|
||||
const submitted = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
|
||||
// 最早可选出行日期:明天
|
||||
const minDate = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return d.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
// 同步儿童年龄数组长度
|
||||
watch(() => form.children, (newVal) => {
|
||||
const current = form.childAges
|
||||
form.childAges = Array.from({ length: newVal }, (_, i) => current[i] ?? '')
|
||||
})
|
||||
|
||||
function increment(field, max) {
|
||||
if (form[field] < max) form[field]++
|
||||
}
|
||||
|
||||
function decrement(field, min = 1) {
|
||||
if (form[field] > min) form[field]--
|
||||
}
|
||||
|
||||
function validate() {
|
||||
errors.name = form.name ? '' : '请填写您的姓名'
|
||||
errors.phone = /^1[3-9]\d{9}$/.test(form.phone) ? '' : '请填写正确的手机号码'
|
||||
return !errors.name && !errors.phone
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validate()) return
|
||||
if (submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
submitError.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/customize', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
wechat: form.wechat,
|
||||
adults: form.adults,
|
||||
children: form.children,
|
||||
childAges: form.childAges,
|
||||
date: form.date,
|
||||
duration: form.duration,
|
||||
activities: form.activities,
|
||||
budget: form.budget,
|
||||
remarks: form.remarks,
|
||||
},
|
||||
})
|
||||
submitted.value = true
|
||||
} catch (err) {
|
||||
const msg = err?.data?.message || err?.message || '提交失败,请稍后重试'
|
||||
submitError.value = msg
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
adults: 2,
|
||||
children: 0,
|
||||
childAges: [],
|
||||
date: '',
|
||||
duration: '',
|
||||
activities: [],
|
||||
budget: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
remarks: '',
|
||||
})
|
||||
errors.name = ''
|
||||
errors.phone = ''
|
||||
submitted.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 信任指标条
|
||||
.trust-bar {
|
||||
background: @color-bg-warm;
|
||||
padding: @space-lg 0;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
|
||||
.trust-list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: @space-2xl;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.respond-lg({
|
||||
gap: @space-xl;
|
||||
});
|
||||
}
|
||||
|
||||
.trust-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
}
|
||||
|
||||
.trust-value {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: 700;
|
||||
color: @color-primary;
|
||||
line-height: 1;
|
||||
|
||||
.trust-unit {
|
||||
font-size: @font-size-md;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
// 整体布局
|
||||
.customize-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: @space-2xl;
|
||||
align-items: start;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr;
|
||||
});
|
||||
}
|
||||
|
||||
// 表单容器
|
||||
.form-wrap {
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 成功状态卡片
|
||||
.success-card {
|
||||
padding: @space-2xl;
|
||||
text-align: center;
|
||||
|
||||
.respond-lg({
|
||||
padding: @space-xl @space-lg;
|
||||
});
|
||||
}
|
||||
|
||||
.success-check {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
line-height: 64px;
|
||||
margin: 0 auto @space-lg;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 700;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-secondary;
|
||||
line-height: 1.6;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.success-wechat {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-md @space-xl;
|
||||
background: @color-bg-warm;
|
||||
border-radius: @border-radius-md;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.wechat-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
.wechat-id {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 700;
|
||||
color: @color-primary;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.success-tip {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
display: inline-block;
|
||||
padding: @space-sm @space-xl;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
background: transparent;
|
||||
color: @color-text-secondary;
|
||||
font-size: @font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// 表单整体
|
||||
.customize-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// 每个表单分区
|
||||
.form-section {
|
||||
border: none;
|
||||
border-bottom: 1px solid @color-border;
|
||||
margin: 0;
|
||||
padding: @space-xl @space-2xl;
|
||||
|
||||
.respond-lg({
|
||||
padding: @space-lg;
|
||||
});
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
font-size: @font-size-md;
|
||||
font-weight: 600;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-lg;
|
||||
padding: 0;
|
||||
float: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.legend-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-hint {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 400;
|
||||
color: @color-text-secondary;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// 出行人数
|
||||
.people-row {
|
||||
display: flex;
|
||||
gap: @space-2xl;
|
||||
|
||||
.respond-lg({
|
||||
gap: @space-xl;
|
||||
});
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
|
||||
label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
input,
|
||||
select {
|
||||
border-color: @color-error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.number-ctrl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
|
||||
button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: @color-bg-warm;
|
||||
border: none;
|
||||
font-size: @font-size-lg;
|
||||
color: @color-text-primary;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background: @color-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.number-display {
|
||||
display: inline-block;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @color-text-primary;
|
||||
padding: 0 @space-sm;
|
||||
line-height: 38px;
|
||||
}
|
||||
|
||||
// 儿童年龄
|
||||
.children-ages {
|
||||
margin-top: @space-lg;
|
||||
padding: @space-lg;
|
||||
background: @color-bg-warm;
|
||||
border-radius: @border-radius-md;
|
||||
}
|
||||
|
||||
.ages-hint {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.ages-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.age-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
|
||||
label {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
// 双列布局(出行计划)
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr;
|
||||
});
|
||||
}
|
||||
|
||||
// 通用输入样式
|
||||
input[type='text'],
|
||||
input[type='tel'],
|
||||
input[type='date'],
|
||||
select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: @space-sm @space-md;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-primary;
|
||||
background: @color-bg-white;
|
||||
transition: border-color 0.15s;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
border-color: @color-primary;
|
||||
box-shadow: 0 0 0 3px fade(@color-primary, 12%);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @color-text-placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
.input-date {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
.error-msg {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-error;
|
||||
}
|
||||
|
||||
.required {
|
||||
font-style: normal;
|
||||
color: @color-error;
|
||||
}
|
||||
|
||||
// 多选特殊需求
|
||||
.checkbox-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-sm @space-md;
|
||||
border: 1.5px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
|
||||
input[type='checkbox'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.check-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
border-color: @color-primary;
|
||||
background: fade(@color-primary, 8%);
|
||||
|
||||
.check-label {
|
||||
color: @color-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单选预算
|
||||
.radio-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-sm @space-md;
|
||||
border: 1.5px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
|
||||
input[type='radio'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
border-color: @color-primary;
|
||||
background: fade(@color-primary, 8%);
|
||||
|
||||
span {
|
||||
color: @color-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 联系方式字段组
|
||||
.contact-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr;
|
||||
});
|
||||
}
|
||||
|
||||
// 提交区
|
||||
.form-submit {
|
||||
padding: @space-xl @space-2xl;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: @space-sm;
|
||||
|
||||
.respond-lg({
|
||||
padding: @space-lg;
|
||||
align-items: stretch;
|
||||
});
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
display: inline-block;
|
||||
padding: @space-md @space-2xl;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
font-size: @font-size-md;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: @border-radius-md;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-dark;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.respond-lg({
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: @space-md;
|
||||
});
|
||||
}
|
||||
|
||||
.submit-hint {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
// 侧边栏
|
||||
.customize-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-lg;
|
||||
position: sticky;
|
||||
top: 84px;
|
||||
|
||||
.respond-lg({
|
||||
position: static;
|
||||
});
|
||||
}
|
||||
|
||||
.aside-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
}
|
||||
|
||||
.aside-title {
|
||||
font-size: @font-size-md;
|
||||
font-weight: 600;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
.aside-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
// 流程步骤
|
||||
.process-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.process-item {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.process-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: fade(@color-primary, 12%);
|
||||
color: @color-primary;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.process-text {
|
||||
strong {
|
||||
display: block;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 微信侧边卡片
|
||||
.aside-wechat {
|
||||
background: @color-bg-warm;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.wechat-id-display {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 700;
|
||||
color: @color-primary;
|
||||
padding: @space-sm @space-md;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-md;
|
||||
text-align: center;
|
||||
margin-bottom: @space-sm;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.wechat-tip {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,390 @@
|
||||
<template>
|
||||
<main class="destination-detail" v-if="dest">
|
||||
<section class="dest-hero">
|
||||
<div class="container">
|
||||
<nav class="breadcrumb" aria-label="面包屑导航">
|
||||
<NuxtLink to="/">首页</NuxtLink> / <NuxtLink to="/destinations">目的地</NuxtLink> / <span>{{ dest.name }}</span>
|
||||
</nav>
|
||||
<div class="hero-content">
|
||||
<span class="dest-tag">{{ dest.tag }}</span>
|
||||
<h1>{{ dest.name }}</h1>
|
||||
<p class="hero-subtitle">{{ dest.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dest-description">
|
||||
<div class="container">
|
||||
<p>{{ dest.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dest-highlights" v-if="dest.highlights?.length">
|
||||
<div class="container">
|
||||
<h2>景点亮点</h2>
|
||||
<ul>
|
||||
<li v-for="(h, i) in dest.highlights" :key="i">{{ h }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dest-season">
|
||||
<div class="container">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="dest-tips" v-if="dest.tips?.length">
|
||||
<div class="container">
|
||||
<h2>实用旅行贴士</h2>
|
||||
<ul>
|
||||
<li v-for="(tip, i) in dest.tips" :key="i">{{ tip }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dest-products" v-if="relatedProducts.length">
|
||||
<div class="container">
|
||||
<h2>包含此景点的行程</h2>
|
||||
<p class="products-intro">以下呼籁定制游产品涵盖{{ dest.name }},由专属领队带队深度游览。</p>
|
||||
<div class="product-links">
|
||||
<NuxtLink
|
||||
v-for="p in relatedProducts"
|
||||
:key="p.id"
|
||||
:to="`/products/${p.id}`"
|
||||
class="product-link"
|
||||
>
|
||||
<span class="product-name">{{ p.name }}</span>
|
||||
<span class="product-meta">{{ p.days }}天{{ p.nights }}晚 · {{ p.tag }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dest-cta">
|
||||
<div class="container">
|
||||
<h2>想去{{ dest.name }}?让定制师为你规划</h2>
|
||||
<p>微信添加 hulai1814,定制师一对一为你安排深度游览方案</p>
|
||||
<NuxtLink to="/contact" class="cta-button">联系定制师</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useSEO from '~/composables/useSEO'
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: destinationsData } = await usePublicApi('destinations-detail')
|
||||
const { data: productsData } = await usePublicApi('products')
|
||||
const { data: winterProductsData } = await usePublicApi('winter-products')
|
||||
const { data: autumnProductsData } = await usePublicApi('autumn-products')
|
||||
|
||||
const route = useRoute()
|
||||
const destId = route.params.id
|
||||
|
||||
const dest = computed(() => (destinationsData.value?.destinations || []).find(d => d.id === destId))
|
||||
|
||||
if (!dest.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: '目的地不存在' })
|
||||
}
|
||||
|
||||
// SEO
|
||||
const seoOverrides = {
|
||||
title: dest.value?.seo?.title,
|
||||
description: dest.value?.seo?.description,
|
||||
}
|
||||
useSEO('destinations', seoOverrides)
|
||||
|
||||
useHead({
|
||||
meta: [
|
||||
{ name: 'keywords', content: dest.value?.seo?.keywords },
|
||||
],
|
||||
})
|
||||
|
||||
// JSON-LD: TouristAttraction
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TouristAttraction',
|
||||
name: dest.value?.name,
|
||||
description: dest.value?.description,
|
||||
url: `https://1814.love/destinations/${dest.value?.id}`,
|
||||
touristType: '家庭游客,亲子游,摄影爱好者',
|
||||
isAccessibleForFree: false,
|
||||
photo: dest.value?.heroImage ? { '@type': 'ImageObject', url: `https://1814.love${dest.value.heroImage}` } : undefined,
|
||||
containedInPlace: {
|
||||
'@type': 'AdministrativeArea',
|
||||
name: '呼伦贝尔',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: '呼伦贝尔市',
|
||||
addressRegion: '内蒙古自治区',
|
||||
addressCountry: 'CN',
|
||||
},
|
||||
},
|
||||
publicAccess: true,
|
||||
availableLanguage: 'Chinese',
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: dest.value?.seo?.title || dest.value?.name,
|
||||
description: dest.value?.seo?.description || dest.value?.description,
|
||||
url: `https://1814.love/destinations/${dest.value?.id}`,
|
||||
author: { '@id': 'https://1814.love/#organization' },
|
||||
publisher: { '@id': 'https://1814.love/#organization' },
|
||||
dateModified: new Date().toISOString().split('T')[0],
|
||||
about: {
|
||||
'@type': 'TouristAttraction',
|
||||
name: dest.value?.name,
|
||||
},
|
||||
keywords: dest.value?.seo?.keywords,
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 面包屑
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '景区攻略', to: '/destinations' },
|
||||
{ text: dest.value?.name },
|
||||
])
|
||||
|
||||
// 关联产品详情——从三个产品线数据中查找
|
||||
const allVersions = computed(() => [
|
||||
...(productsData.value?.versions || []),
|
||||
...(winterProductsData.value?.versions || []),
|
||||
...(autumnProductsData.value?.versions || []),
|
||||
])
|
||||
const relatedProducts = computed(() => (dest.value?.relatedProducts || [])
|
||||
.map(pid => allVersions.value.find(v => v.id === pid))
|
||||
.filter(Boolean)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.dest-hero {
|
||||
background: linear-gradient(135deg, #f0f5f1, #e8ede9);
|
||||
padding: 40px 0 48px;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
color: #7a857c;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: #3a7d44;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dest-tag {
|
||||
display: inline-block;
|
||||
background: #3a7d44;
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #1a1f1c;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-size: 17px;
|
||||
color: #4a5e4d;
|
||||
}
|
||||
.dest-description {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.dest-description p {
|
||||
font-size: 17px;
|
||||
line-height: 1.9;
|
||||
color: #3d4a40;
|
||||
}
|
||||
.dest-highlights {
|
||||
padding: 40px 0;
|
||||
background: #fafbfa;
|
||||
}
|
||||
.dest-highlights h2,
|
||||
.dest-season h2,
|
||||
.dest-tips h2,
|
||||
.dest-products h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: #1a1f1c;
|
||||
}
|
||||
.dest-highlights ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.dest-highlights li {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #eef0ef;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #3d4a40;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.dest-highlights li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #3a7d44;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dest-season {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.season-card {
|
||||
background: #f0f5f1;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.season-primary,
|
||||
.season-secondary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.season-label {
|
||||
font-size: 12px;
|
||||
color: #7a857c;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.season-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #3a7d44;
|
||||
}
|
||||
.season-desc {
|
||||
flex: 1 1 100%;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: #4a5e4d;
|
||||
margin: 0;
|
||||
}
|
||||
.dest-tips {
|
||||
padding: 40px 0;
|
||||
background: #fafbfa;
|
||||
}
|
||||
.dest-tips ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.dest-tips li {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #eef0ef;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #3d4a40;
|
||||
padding-left: 28px;
|
||||
position: relative;
|
||||
}
|
||||
.dest-tips li::before {
|
||||
content: '💡';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.dest-products {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.products-intro {
|
||||
font-size: 15px;
|
||||
color: #4a5e4d;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.product-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.product-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #d8e4da;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.product-link:hover {
|
||||
border-color: #3a7d44;
|
||||
background: #f0f5f1;
|
||||
}
|
||||
.product-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1f1c;
|
||||
}
|
||||
.product-meta {
|
||||
font-size: 14px;
|
||||
color: #7a857c;
|
||||
}
|
||||
.dest-cta {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f0f5f1, #e8ede9);
|
||||
}
|
||||
.dest-cta h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: #1a1f1c;
|
||||
}
|
||||
.dest-cta p {
|
||||
color: #4a5e4d;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: #3a7d44;
|
||||
color: #fff;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.cta-button:hover {
|
||||
background: #2e6435;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div class="page-destinations">
|
||||
<LayoutPageHero
|
||||
title="呼伦贝尔核心景区攻略"
|
||||
subtitle="深耕呼伦贝尔11年,带你发现最真实的草原秘境"
|
||||
:breadcrumbs="[{ text: '景区攻略' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR:告诉 AI/搜索引擎这个页面回答了什么 -->
|
||||
<section class="page-tldr" aria-label="景区攻略摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔核心景区覆盖草原、湿地、森林、边境、火山地质等多种景观类型。本页梳理了呼籁旅行三大产品线(额吉的故乡·夏季、游牧的森林·秋季、嗨冰雪·冬季)覆盖的所有核心景区,包括莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山国家森林公园、大兴安岭莫尔道嘎、敖鲁古雅驯鹿部落、根河等,提供每处景区的实用攻略、最佳时间和旅行贴士。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="destinationsData" class="section">
|
||||
<div class="container">
|
||||
<!-- 季节筛选 -->
|
||||
<div class="dest-filter">
|
||||
<button
|
||||
v-for="s in seasonFilters"
|
||||
:key="s.key"
|
||||
class="filter-btn"
|
||||
:class="{ 'filter-btn--active': activeSeason === s.key }"
|
||||
@click="activeSeason = s.key"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span class="filter-count">{{ getSeasonCount(s.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="dest-search">
|
||||
<svg class="dest-search__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索景区名称或关键词…"
|
||||
class="dest-search__input"
|
||||
/>
|
||||
<button v-if="searchQuery" class="dest-search__clear" @click="searchQuery = ''">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 景区列表 -->
|
||||
<div v-if="filteredDestinations.length === 0" class="dest-empty">
|
||||
<p>没有找到匹配的景区,试试其他关键词</p>
|
||||
</div>
|
||||
|
||||
<div class="dest-grid">
|
||||
<NuxtLink
|
||||
v-for="dest in filteredDestinations"
|
||||
:key="dest.id"
|
||||
:to="`/destinations/${dest.id}`"
|
||||
class="dest-card reveal"
|
||||
>
|
||||
<div class="dest-card__header">
|
||||
<span class="dest-card__tag">{{ dest.tag }}</span>
|
||||
<span class="dest-card__season">{{ dest.bestSeason.primary }}</span>
|
||||
</div>
|
||||
<h2 class="dest-card__name">{{ dest.name }}</h2>
|
||||
<p class="dest-card__subtitle">{{ dest.subtitle }}</p>
|
||||
<p class="dest-card__desc">{{ dest.description.substring(0, 100) }}…</p>
|
||||
<div class="dest-card__highlights">
|
||||
<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('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>
|
||||
</div>
|
||||
<span class="dest-card__link">查看攻略 →</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="dest-cta">
|
||||
<h2>不确定去哪些景区?让定制师帮你组合</h2>
|
||||
<p>呼籁旅行定制师深耕呼伦贝尔11年,根据你的时间、季节、孩子年龄,帮你搭配最合适的景区组合和行程节奏。</p>
|
||||
<div class="dest-cta__links">
|
||||
<CommonInternalLink to="/contact" text="免费咨询定制师" />
|
||||
<CommonInternalLink to="/products" text="查看定制游产品" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useSEO from '~/composables/useSEO'
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
import useScrollReveal from '~/composables/useScrollReveal'
|
||||
|
||||
const { data: destinationsData } = await usePublicApi('destinations-detail')
|
||||
|
||||
const destinations = computed(() => destinationsData.value?.destinations || [])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeSeason = ref('all')
|
||||
|
||||
const seasonFilters = [
|
||||
{ key: 'all', label: '全部景区' },
|
||||
{ key: 'summer', label: '夏季·额吉的故乡' },
|
||||
{ key: 'autumn', label: '秋季·游牧的森林' },
|
||||
{ key: 'winter', label: '冬季·嗨冰雪' },
|
||||
]
|
||||
|
||||
function getSeasonCount(key) {
|
||||
if (key === 'all') return destinations.value.length
|
||||
return destinations.value.filter(d => d.seasons?.includes(key)).length
|
||||
}
|
||||
|
||||
const filteredDestinations = computed(() => {
|
||||
return destinations.value.filter(d => {
|
||||
const matchSeason = activeSeason.value === 'all' || d.seasons?.includes(activeSeason.value)
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const matchSearch = !q
|
||||
|| d.name.toLowerCase().includes(q)
|
||||
|| d.subtitle?.toLowerCase().includes(q)
|
||||
|| d.tag.toLowerCase().includes(q)
|
||||
|| (d.highlights || []).some(h => h.toLowerCase().includes(q))
|
||||
return matchSeason && matchSearch
|
||||
})
|
||||
})
|
||||
|
||||
useSEO('destinations', {
|
||||
title: '呼伦贝尔核心景区攻略 - 莫日格勒河 额尔古纳 阿尔山等景点详解 | 呼籁旅行',
|
||||
description: '呼籁旅行深耕呼伦贝尔11年,为你梳理三大产品线覆盖的核心景区攻略:莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山、大兴安岭、敖鲁古雅驯鹿部落、根河,含实用贴士和最佳时间。',
|
||||
})
|
||||
|
||||
useHead({
|
||||
meta: [
|
||||
{ name: 'keywords', content: '呼伦贝尔景点,呼伦贝尔旅游攻略,莫日格勒河,额尔古纳湿地,白桦林,黑山头,满洲里,阿尔山,大兴安岭,敖鲁古雅,根河,呼伦贝尔景区' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: '呼伦贝尔核心旅游景区',
|
||||
description: '呼籁旅行三大产品线覆盖的呼伦贝尔核心景区,含草原、湿地、森林、边境、火山地质等多种景观',
|
||||
url: 'https://1814.love/destinations',
|
||||
numberOfItems: destinations.value.length,
|
||||
itemListElement: destinations.value.map((d, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: d.name,
|
||||
description: d.description.substring(0, 120),
|
||||
url: `https://1814.love/destinations/${d.id}`,
|
||||
item: {
|
||||
'@type': 'TouristAttraction',
|
||||
name: d.name,
|
||||
description: d.subtitle,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '景区攻略' },
|
||||
])
|
||||
|
||||
useScrollReveal()
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 筛选
|
||||
.dest-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-sm @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
cursor: pointer;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: @color-primary;
|
||||
border-color: @color-primary;
|
||||
color: #fff;
|
||||
|
||||
.filter-count {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// 搜索
|
||||
.dest-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 480px;
|
||||
margin-bottom: @space-2xl;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
padding: 0 @space-md;
|
||||
transition: border-color @transition-fast;
|
||||
|
||||
&:focus-within {
|
||||
border-color: @color-primary;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
color: @color-text-muted;
|
||||
margin-right: @space-sm;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
background: transparent;
|
||||
padding: 10px 0;
|
||||
|
||||
&::placeholder {
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: @color-text-muted;
|
||||
padding: @space-xs;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color @transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.dest-empty {
|
||||
text-align: center;
|
||||
padding: @space-3xl;
|
||||
color: @color-text-muted;
|
||||
font-size: @font-size-md;
|
||||
}
|
||||
|
||||
// 景区网格
|
||||
.dest-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: 1fr 1fr;
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
});
|
||||
}
|
||||
|
||||
// 景区卡片
|
||||
.dest-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-lg;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
display: inline-block;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
padding: 3px @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
&__season {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
line-height: @line-height-tight;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
margin: 0;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
line-height: @line-height-base;
|
||||
color: @color-text-secondary;
|
||||
margin: 0;
|
||||
.text-clamp(3);
|
||||
}
|
||||
|
||||
&__highlights {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-xs;
|
||||
}
|
||||
|
||||
&__highlight {
|
||||
display: inline-block;
|
||||
padding: 2px @space-sm;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
.text-clamp(1);
|
||||
}
|
||||
|
||||
&__seasons {
|
||||
display: flex;
|
||||
gap: @space-xs;
|
||||
margin-top: auto;
|
||||
padding-top: @space-xs;
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
transition: color @transition-fast;
|
||||
}
|
||||
|
||||
&:hover &__link {
|
||||
color: @color-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
// 季节标签
|
||||
.season-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: @font-weight-medium;
|
||||
|
||||
&--summer {
|
||||
background: #E8F5E9;
|
||||
color: #2E7D32;
|
||||
}
|
||||
|
||||
&--autumn {
|
||||
background: #FFF3E0;
|
||||
color: #E65100;
|
||||
}
|
||||
|
||||
&--winter {
|
||||
background: #E3F2FD;
|
||||
color: #1565C0;
|
||||
}
|
||||
}
|
||||
|
||||
// CTA
|
||||
.dest-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;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dest-cta__links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: @space-xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="page-faq">
|
||||
<!-- 页面标题 -->
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
:subtitle="`${totalFAQ}个高频问题详细解答,帮您做出最好的选择`"
|
||||
:breadcrumbs="[{ text: '常见问答' }]"
|
||||
background-image="/images/summer-camp/grassland-group.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR:告诉 AI/搜索引擎这个页面回答了什么 -->
|
||||
<section class="page-tldr" aria-label="常见问答摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>本页汇总呼籁旅行被问到最多的{{ totalFAQ }}个问题,涵盖价格区间、最佳出行时间、适合人群、行程安排、安全保障、退改条款等核心关切,按分类整理并支持关键词搜索。还有疑问可直接联系定制师。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="faqData" class="section">
|
||||
<div class="container">
|
||||
<!-- 搜索框 -->
|
||||
<div class="faq-search-wrap">
|
||||
<div class="faq-search-box">
|
||||
<span class="faq-search-icon" aria-hidden="true">🔍</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="faq-search-input"
|
||||
placeholder="搜索问题,如:多少钱、几月份去、穿什么…"
|
||||
aria-label="搜索常见问题"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
<button v-if="searchQuery" class="faq-search-clear" aria-label="清除搜索" @click="clearSearch">✕</button>
|
||||
</div>
|
||||
<p v-if="searchQuery" class="faq-search-summary">
|
||||
<template v-if="searchResultCount > 0">
|
||||
找到 <strong>{{ searchResultCount }}</strong> 条相关问题
|
||||
</template>
|
||||
<template v-else>
|
||||
未找到相关问题,试试其他关键词
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果视图 -->
|
||||
<div v-if="searchQuery" class="faq-content">
|
||||
<template v-if="searchResultCount > 0">
|
||||
<div v-for="cat in filteredCategories" :key="cat.id" class="faq-search-group">
|
||||
<h2 class="faq-category-label">{{ cat.name }}</h2>
|
||||
<div class="faq-category-list">
|
||||
<FaqItem
|
||||
v-for="q in cat.questions"
|
||||
:key="q.id"
|
||||
:question="q.question"
|
||||
:answer="q.answer"
|
||||
:related-links="q.relatedLinks"
|
||||
:initial-open="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="faq-no-result">
|
||||
<p>没有找到包含"{{ searchQuery }}"的问题</p>
|
||||
<button class="faq-no-result-btn" @click="clearSearch">查看全部问题</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认分类视图 -->
|
||||
<template v-else>
|
||||
<div class="faq-nav-sticky">
|
||||
<FaqCategoryNav
|
||||
:categories="categories"
|
||||
:active-cat="activeCat"
|
||||
@select="scrollToCategory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="faq-content">
|
||||
<FaqCategory
|
||||
v-for="(cat, index) in categories"
|
||||
:key="cat.id"
|
||||
:category="cat"
|
||||
:initial-open="index === 0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内链闭环:FAQ→产品 -->
|
||||
<CommonSectionCTA
|
||||
title="还有疑问?"
|
||||
description="查看我们的产品详情,或直接联系定制师获取专属方案"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看4-7天亲子游产品' },
|
||||
{ to: '/contact', text: '联系定制师获取方案' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFAQPageSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: faqData } = await usePublicApi('faq')
|
||||
|
||||
// SEO - 最重要的页面
|
||||
const seo = useSEO('faq')
|
||||
|
||||
// JSON-LD: FAQPage schema(核心SEO资产)
|
||||
const categories = computed(() => faqData.value?.categories || [])
|
||||
const totalFAQ = computed(() => categories.value.reduce((sum, c) => sum + c.questions.length, 0))
|
||||
useFAQPageSchema(categories.value)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '常见问答' },
|
||||
])
|
||||
|
||||
// 搜索功能 — 支持 URL ?q= 参数初始化(对应 WebSite SearchAction)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const searchQuery = ref(route.query.q ? String(route.query.q) : '')
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return categories.value
|
||||
return categories.value
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
questions: cat.questions.filter(
|
||||
item =>
|
||||
item.question.toLowerCase().includes(q) ||
|
||||
item.answer.toLowerCase().includes(q)
|
||||
),
|
||||
}))
|
||||
.filter(cat => cat.questions.length > 0)
|
||||
})
|
||||
|
||||
const searchResultCount = computed(() =>
|
||||
filteredCategories.value.reduce((sum, c) => sum + c.questions.length, 0)
|
||||
)
|
||||
|
||||
function onSearchInput() {
|
||||
const q = searchQuery.value.trim()
|
||||
router.replace({ query: q ? { q } : {} })
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
// 分类锚点导航 + 滚动联动
|
||||
const activeCat = ref(categories.value?.[0]?.id || '')
|
||||
|
||||
function scrollToCategory(id) {
|
||||
activeCat.value = id
|
||||
if (import.meta.client) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动时自动高亮当前可见分类
|
||||
onMounted(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
activeCat.value = entry.target.id
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-100px 0px -60% 0px', threshold: 0 }
|
||||
)
|
||||
|
||||
categories.value.forEach((cat) => {
|
||||
const el = document.getElementById(cat.id)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
|
||||
onUnmounted(() => observer.disconnect())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 搜索框
|
||||
.faq-search-wrap {
|
||||
max-width: 800px;
|
||||
margin: 0 auto @space-xl;
|
||||
}
|
||||
|
||||
.faq-search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: @color-bg-white;
|
||||
border: 2px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-sm @space-lg;
|
||||
transition: border-color @transition-fast, box-shadow @transition-fast;
|
||||
|
||||
&:focus-within {
|
||||
border-color: @color-primary;
|
||||
box-shadow: 0 0 0 3px rgba(58, 125, 68, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.faq-search-icon {
|
||||
font-size: @font-size-base;
|
||||
margin-right: @space-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.faq-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-primary;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-search-clear {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: @color-text-muted;
|
||||
font-size: @font-size-sm;
|
||||
padding: 0 0 0 @space-sm;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: @color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-search-summary {
|
||||
margin-top: @space-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
|
||||
strong {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索结果
|
||||
.faq-search-group {
|
||||
margin-bottom: @space-2xl;
|
||||
}
|
||||
|
||||
.faq-category-label {
|
||||
font-size: @font-size-xl;
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-lg;
|
||||
padding-bottom: @space-sm;
|
||||
border-bottom: 2px solid @color-primary-lighter;
|
||||
}
|
||||
|
||||
.faq-category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.faq-no-result {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: @space-2xl;
|
||||
color: @color-text-secondary;
|
||||
|
||||
p {
|
||||
margin-bottom: @space-lg;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-no-result-btn {
|
||||
display: inline-block;
|
||||
padding: @space-sm @space-xl;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: @border-radius-md;
|
||||
cursor: pointer;
|
||||
font-size: @font-size-sm;
|
||||
transition: background @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认视图
|
||||
.faq-nav-sticky {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 10;
|
||||
background: @color-bg-white;
|
||||
padding-top: @space-sm;
|
||||
padding-bottom: @space-sm;
|
||||
margin-bottom: @space-lg;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
top: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
<template>
|
||||
<div class="page-gallery">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="自营摄影团队 · 200+件蒙古服饰 · 专属秘境取景地"
|
||||
:breadcrumbs="[{ text: '旅拍作品' }]"
|
||||
background-image="/images/seasons/autumn-forest.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="旅拍作品摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行旅拍作品展示:自营摄影团队随行拍摄,200+件蒙古服饰可选,专属秘境取景地。无需单独预约,旅行中自然记录家庭珍贵瞬间。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 团队数据亮点 -->
|
||||
<section v-if="gallery" class="gallery-stats-bar">
|
||||
<div class="container">
|
||||
<div class="gallery-stats">
|
||||
<div v-for="stat in gallery.intro.stats" :key="stat.label" class="gallery-stat">
|
||||
<span class="gallery-stat__value">{{ stat.value }}</span>
|
||||
<span class="gallery-stat__label">{{ stat.label }}</span>
|
||||
<span class="gallery-stat__note">{{ stat.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 旅拍团队介绍 -->
|
||||
<section v-if="gallery" class="gallery-intro section">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="呼籁旅拍 · 草原最美的记忆"
|
||||
subtitle="随行拍摄,无需单独预约,每组旅拍都是独一无二的故事"
|
||||
align="center"
|
||||
/>
|
||||
<p class="gallery-intro__desc">{{ gallery.intro.description }}</p>
|
||||
<div class="gallery-intro__highlights">
|
||||
<div class="gallery-highlight">
|
||||
<span class="gallery-highlight__icon">👘</span>
|
||||
<div>
|
||||
<strong>200+件蒙古服饰</strong>
|
||||
<p>传统袍服、节日盛装、亲子套装,按体型量身挑选</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-highlight">
|
||||
<span class="gallery-highlight__icon">📷</span>
|
||||
<div>
|
||||
<strong>专业旅拍摄影师</strong>
|
||||
<p>全程随行,把握每一个自然光线最好的瞬间</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-highlight">
|
||||
<span class="gallery-highlight__icon">🏔️</span>
|
||||
<div>
|
||||
<strong>独家秘境取景地</strong>
|
||||
<p>莫日格勒河源头、营地花海、白桦林深处,不对外开放</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 分类筛选 + 作品展示 -->
|
||||
<section v-if="gallery" class="section section--gray">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="旅拍作品"
|
||||
subtitle="每一帧都是真实客户的草原记忆"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<div class="gallery-filter">
|
||||
<button
|
||||
v-for="cat in gallery.categories"
|
||||
:key="cat.key"
|
||||
class="filter-btn"
|
||||
:class="{ 'filter-btn--active': activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
{{ cat.label }}
|
||||
<span class="filter-count">{{ getCategoryCount(cat.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 作品瀑布流 -->
|
||||
<div class="gallery-masonry">
|
||||
<div
|
||||
v-for="work in filteredWorks"
|
||||
:key="work.id"
|
||||
class="gallery-item reveal"
|
||||
>
|
||||
<div class="gallery-card" :class="`gallery-card--${work.orientation || 'landscape'}`">
|
||||
<!-- 图片区域 -->
|
||||
<div class="gallery-card__img-wrap">
|
||||
<img
|
||||
:src="work.image"
|
||||
:alt="`${work.title} - 呼籁草原旅拍`"
|
||||
class="gallery-card__img"
|
||||
loading="lazy"
|
||||
@error="handleImgError($event, work.id)"
|
||||
/>
|
||||
<!-- 占位背景(图片加载失败时可见) -->
|
||||
<div class="gallery-card__placeholder" aria-hidden="true">
|
||||
<span class="gallery-card__placeholder-icon">📸</span>
|
||||
<span class="gallery-card__placeholder-text">{{ work.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类标签 -->
|
||||
<span class="gallery-card__badge">{{ getCategoryLabel(work.category) }}</span>
|
||||
|
||||
<!-- 底部信息条(常态可见) -->
|
||||
<div class="gallery-card__info-bar">
|
||||
<span class="gallery-card__location">📍 {{ work.location }}</span>
|
||||
<span class="gallery-card__season">{{ work.season }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮遮罩(hover 可见) -->
|
||||
<div class="gallery-card__overlay">
|
||||
<div class="gallery-card__overlay-inner">
|
||||
<h3 class="gallery-card__title">{{ work.title }}</h3>
|
||||
<p class="gallery-card__description">{{ work.description }}</p>
|
||||
<ul class="gallery-card__meta">
|
||||
<li>📍 {{ work.location }}</li>
|
||||
<li>🌿 {{ work.season }}</li>
|
||||
<li>👘 {{ work.costumeType }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredWorks.length === 0" class="gallery-empty">
|
||||
<p>暂无该分类的作品</p>
|
||||
</div>
|
||||
|
||||
<!-- 说明文字 -->
|
||||
<p class="gallery-notice">
|
||||
以上均为呼籁旅拍真实客户作品,已获客户授权展示。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 预约旅拍 CTA -->
|
||||
<CommonSectionCTA
|
||||
title="预约您的草原旅拍"
|
||||
description="旅拍随行程一并安排,无需单独预约。告诉定制师您的旅拍偏好,我们提前准备好服饰和取景计划。"
|
||||
:links="[
|
||||
{ to: '/customize', text: '立即预约,开始定制行程' },
|
||||
{ to: '/contact', text: '咨询旅拍详情' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema, useGallerySchema } from '~/composables/useJsonLd'
|
||||
import useScrollReveal from '~/composables/useScrollReveal'
|
||||
|
||||
const { data: gallery } = await usePublicApi('gallery')
|
||||
|
||||
const seo = useSEO('gallery')
|
||||
useScrollReveal()
|
||||
|
||||
// 分类筛选
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const filteredWorks = computed(() => {
|
||||
if (activeCategory.value === 'all') return gallery.value?.works || []
|
||||
return (gallery.value?.works || []).filter(w => w.category === activeCategory.value)
|
||||
})
|
||||
|
||||
function getCategoryCount(key) {
|
||||
if (key === 'all') return gallery.value?.works?.length || 0
|
||||
return (gallery.value?.works || []).filter(w => w.category === key).length
|
||||
}
|
||||
|
||||
function getCategoryLabel(key) {
|
||||
const cat = (gallery.value?.categories || []).find(c => c.key === key)
|
||||
return cat ? cat.label : key
|
||||
}
|
||||
|
||||
// 图片加载失败时显示占位背景
|
||||
function handleImgError(event, id) {
|
||||
event.target.style.display = 'none'
|
||||
}
|
||||
|
||||
// 结构化数据
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '旅拍作品' },
|
||||
])
|
||||
useGallerySchema(gallery.value)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 数据亮点条
|
||||
.gallery-stats-bar {
|
||||
background: @color-primary-bg;
|
||||
padding: @space-xl 0;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
|
||||
.gallery-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-lg;
|
||||
text-align: center;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.gallery-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&__value {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
&__note {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// 介绍区
|
||||
.gallery-intro {
|
||||
&__desc {
|
||||
max-width: 680px;
|
||||
margin: @space-lg auto @space-2xl;
|
||||
text-align: center;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-intro__highlights {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.gallery-highlight {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: @space-md;
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
|
||||
&__icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 分类筛选
|
||||
.gallery-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-2xl;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-xs;
|
||||
padding: @space-sm @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
cursor: pointer;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: @color-primary;
|
||||
border-color: @color-primary;
|
||||
color: #fff;
|
||||
|
||||
.filter-count {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// 瀑布流布局
|
||||
.gallery-masonry {
|
||||
columns: 1;
|
||||
column-gap: @space-lg;
|
||||
|
||||
.respond-md({
|
||||
columns: 2;
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
columns: 3;
|
||||
});
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
// 作品卡片
|
||||
.gallery-card {
|
||||
position: relative;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
background: @color-bg-gray;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.gallery-card__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gallery-card__img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
// 不同比例
|
||||
&--portrait .gallery-card__img-wrap {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
&--landscape .gallery-card__img-wrap {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
&__img-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: @color-bg-gray;
|
||||
// 默认比例兜底
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
&__img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform @transition-slow;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// 占位样式(图片未加载时可见)
|
||||
&__placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: @space-sm;
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&__placeholder-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&__placeholder-text {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
text-align: center;
|
||||
padding: 0 @space-md;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
&__placeholder-id {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 2px @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
}
|
||||
|
||||
// 分类标签
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: @space-sm;
|
||||
left: @space-sm;
|
||||
z-index: 3;
|
||||
padding: 3px @space-sm;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// 底部信息条
|
||||
&__info-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: @space-sm @space-md;
|
||||
background: @color-bg-white;
|
||||
border-top: 1px solid @color-border;
|
||||
}
|
||||
|
||||
&__location {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
.text-clamp(1);
|
||||
}
|
||||
|
||||
&__season {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
white-space: nowrap;
|
||||
margin-left: @space-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// hover 遮罩
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.85) 0%,
|
||||
rgba(0, 0, 0, 0.4) 50%,
|
||||
rgba(0, 0, 0, 0.1) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity @transition-base;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__overlay-inner {
|
||||
padding: @space-lg;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
margin: 0 0 @space-sm;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: @font-size-sm;
|
||||
line-height: @line-height-base;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0 0 @space-md;
|
||||
.text-clamp(3);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-xs @space-md;
|
||||
|
||||
li {
|
||||
font-size: @font-size-xs;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.gallery-empty {
|
||||
text-align: center;
|
||||
padding: @space-3xl;
|
||||
color: @color-text-muted;
|
||||
font-size: @font-size-md;
|
||||
}
|
||||
|
||||
// 说明文字
|
||||
.gallery-notice {
|
||||
margin-top: @space-2xl;
|
||||
text-align: center;
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
padding: @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px dashed @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="page-guide-custom">
|
||||
<LayoutPageHero
|
||||
title="呼伦贝尔定制游怎么选"
|
||||
subtitle="定制游 vs 跟团游 vs 自由行,一篇讲透"
|
||||
:breadcrumbs="[{ text: '出行指南', to: '/guides' }, { text: '定制游怎么选' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
|
||||
<section class="page-tldr" aria-label="定制游摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔定制游是一家一单一车的私家出行方式,区别于传统跟团游的拼团大巴和走马观花。本文解释定制游的优势、适合人群、价格构成,帮你判断是否适合选择定制游。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="section">
|
||||
<div class="container article-body">
|
||||
|
||||
<h2>什么是定制游</h2>
|
||||
<p>定制游的核心是「一家一单一车」——你的家庭独享一辆车和一位领队,行程根据你的需求量身安排,不与陌生人拼团。出发时间、游玩节奏、停留时长都可以灵活调整,孩子累了可以多休息,遇到好风景可以多停一会儿。</p>
|
||||
<p>这不是高价版的跟团游,而是完全不同的旅行方式。</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema, useFAQPageSchema } from '~/composables/useJsonLd'
|
||||
|
||||
useHead({
|
||||
title: '呼伦贝尔定制游怎么选 - 定制游vs跟团游vs自由行 - 呼籁旅行',
|
||||
meta: [
|
||||
{ name: 'description', content: '呼伦贝尔定制游是一家一单一车的私家出行方式。本文对比定制游、跟团游、自由行的区别,解释定制游的价格构成和适合人群,帮你判断是否适合选择呼伦贝尔定制游。' },
|
||||
{ name: 'keywords', content: '呼伦贝尔定制游,定制游和跟团游的区别,呼伦贝尔旅游怎么选,呼伦贝尔一家一车,呼伦贝尔私家团,呼伦贝尔亲子游' },
|
||||
],
|
||||
})
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '出行指南', to: '/guides' },
|
||||
{ text: '定制游怎么选' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.article-body {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: @space-2xl;
|
||||
|
||||
h2 {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: @space-2xl 0 @space-md;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: @space-lg;
|
||||
margin-bottom: @space-md;
|
||||
|
||||
li {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compare-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: @space-lg 0 @space-xl;
|
||||
}
|
||||
|
||||
.compare-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: @font-size-sm;
|
||||
|
||||
th, td {
|
||||
padding: @space-sm @space-md;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
|
||||
th {
|
||||
background: @color-bg-gray;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
|
||||
&:nth-child(2) {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
td {
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.article-cta {
|
||||
margin-top: @space-2xl;
|
||||
padding: @space-xl;
|
||||
background: @color-primary-bg;
|
||||
border-radius: @border-radius-lg;
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
font-size: @font-size-lg;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-links {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
display: inline-block;
|
||||
padding: @space-sm @space-lg;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
transition: all @transition-fast;
|
||||
|
||||
&--primary {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
&:hover { background: @color-primary-light; }
|
||||
}
|
||||
|
||||
&--outline {
|
||||
background: @color-bg-white;
|
||||
color: @color-primary;
|
||||
border: 1px solid @color-primary;
|
||||
&:hover { background: @color-primary; color: #fff; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
<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>
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="page-home">
|
||||
<!-- Hero Banner -->
|
||||
<HomeHeroBanner :seo="seo" />
|
||||
|
||||
<!-- 身份定义 + TL;DR(GEO/SEO 核心锚点:告诉 AI 这个站点是谁) -->
|
||||
<section class="page-tldr" aria-label="品牌身份摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行是深耕呼伦贝尔11年的专属旅行品牌,创立于2015年,已服务超10000个家庭。不做拼团、不走马观花;提供额吉的故乡V9系列定制游(4-7天)、小蒙马亲子夏令营(7天6晚)及秋冬季专属产品,全程一单一车、独立成团。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 信任数据 -->
|
||||
<HomeTrustBar v-if="brand?.trustStats" :stats="brand.trustStats" class="reveal" />
|
||||
|
||||
<!-- 产品预览 -->
|
||||
<HomeProductPreview class="reveal" />
|
||||
|
||||
<!-- 产品语义树(帮助 AI 按维度理解产品分类)-->
|
||||
<section class="section section--gray reveal" aria-label="产品分类导航">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="怎么找到适合你的行程" label="FIND YOUR TRIP" subtitle="按天数 · 按人群 · 按季节 三个维度快速定位" />
|
||||
<div class="product-tree-grid">
|
||||
<div class="product-tree-card product-tree-card--days">
|
||||
<div class="product-tree-icon">
|
||||
<svg width="28" height="28" 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="product-tree-card__title">按出行天数选</h3>
|
||||
<ul class="product-tree-list">
|
||||
<li><NuxtLink to="/products">5天4晚</NuxtLink>:森林版/草原版,覆盖核心景观</li>
|
||||
<li><NuxtLink to="/products">6天5晚</NuxtLink>:亲子版/探索版,深度体验</li>
|
||||
<li><NuxtLink to="/products">7天6晚</NuxtLink>:亲子深度版/旷野版,全线贯穿</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="product-tree-card product-tree-card--people">
|
||||
<div class="product-tree-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||
</div>
|
||||
<h3 class="product-tree-card__title">按出行人群选</h3>
|
||||
<ul class="product-tree-list">
|
||||
<li><NuxtLink to="/products">亲子家庭</NuxtLink>:儿童友好、安全有趣</li>
|
||||
<li><NuxtLink to="/summer-camp">研学营家庭</NuxtLink>:小蒙马7天自然教育夏令营</li>
|
||||
<li><NuxtLink to="/products">摄影爱好者</NuxtLink>:专属秘境取景、旅拍服务</li>
|
||||
<li><NuxtLink to="/products">两代三代出行</NuxtLink>:老人孩子均适合的舒适线路</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="product-tree-card product-tree-card--season">
|
||||
<div class="product-tree-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</div>
|
||||
<h3 class="product-tree-card__title">按出行季节选</h3>
|
||||
<ul class="product-tree-list">
|
||||
<li><NuxtLink to="/products">夏季(6-8月)</NuxtLink>:草原绿、那达慕、骑马露营</li>
|
||||
<li><NuxtLink to="/products/autumn">秋季(9月)</NuxtLink>:白桦林金黄,摄影圣地</li>
|
||||
<li><NuxtLink to="/products/winter">冬季(12-2月)</NuxtLink>:冰雪那达慕、驯鹿、极寒</li>
|
||||
<li><NuxtLink to="/calendar">查看全年出行日历 →</NuxtLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-tree-cta">
|
||||
<CommonInternalLink to="/selector" text="不确定选哪个?用选版工具1分钟匹配 →" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心差异化 -->
|
||||
<HomeDifferentiatorList v-if="brand?.differentiators" :items="brand.differentiators" class="reveal" />
|
||||
|
||||
<!-- 目的地对比 -->
|
||||
<HomeDestinationCompare class="reveal" />
|
||||
|
||||
<!-- 知识库入口(告诉 AI 这是持续产出内容的权威站点)-->
|
||||
<section class="section reveal" aria-label="旅行知识库">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="11年积累的呼伦贝尔知识库" label="KNOWLEDGE" 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>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/guides" 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"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
</div>
|
||||
<h3 class="knowledge-hub-card__title">出行准备指南</h3>
|
||||
<p class="knowledge-hub-card__desc">证件清单、分月穿搭、行李打包、活动安全、幼童须知</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/blog" 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"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
|
||||
</div>
|
||||
<h3 class="knowledge-hub-card__title">旅行攻略专栏</h3>
|
||||
<p class="knowledge-hub-card__desc">亲子游完全指南、避坑经验、旅拍穿搭,持续更新</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/destinations" 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"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg>
|
||||
</div>
|
||||
<h3 class="knowledge-hub-card__title">核心景区攻略</h3>
|
||||
<p class="knowledge-hub-card__desc">莫日格勒河、额尔古纳湿地、阿尔山等呼伦贝尔核心景区深度解读</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ预览 -->
|
||||
<section v-if="faq" class="section section--gray reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="常见问答" label="FAQ" subtitle="您可能想了解的问题" />
|
||||
<div class="faq-preview">
|
||||
<details v-for="q in featuredFAQ" :key="q.id" class="faq-preview-item">
|
||||
<summary class="faq-preview-q">
|
||||
<span class="faq-q-text">{{ q.question }}</span>
|
||||
<span class="faq-toggle-icon" />
|
||||
</summary>
|
||||
<p class="faq-preview-a">{{ truncate(q.answer, 200) }}</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="faq-preview-more">
|
||||
<CommonInternalLink to="/faq" :text="`查看全部${totalFAQ}个常见问题`" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 评价精选 -->
|
||||
<HomeReviewHighlights v-if="featuredReviews.length" :reviews="featuredReviews" class="reveal" />
|
||||
|
||||
<!-- 内链闭环:首页→产品→选版→定制 -->
|
||||
<CommonSectionCTA
|
||||
title="准备好出发了吗?"
|
||||
description="还不确定选哪个版本?先用选版工具匹配一下,再看价格明细,最后找定制师拿专属方案。"
|
||||
:links="[
|
||||
{ to: '/selector', text: '看看哪个版本适合你' },
|
||||
{ to: '/pricing', text: '了解费用明细' },
|
||||
{ to: '/customize', text: '免费获取专属方案' }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOrganizationSchema, useWebSiteSchema, useSiteNavigationSchema } from '~/composables/useJsonLd'
|
||||
|
||||
// 从 API 获取数据(替代静态 JSON 导入)
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: faq } = await usePublicApi('faq')
|
||||
const { data: reviews } = await usePublicApi('reviews')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
const { data: navData } = await usePublicApi('navigation')
|
||||
|
||||
// SEO
|
||||
const seo = useSEO('home')
|
||||
|
||||
// 滚动入场动画
|
||||
useScrollReveal()
|
||||
|
||||
// JSON-LD(需要 .value 访问 ref 数据)
|
||||
useOrganizationSchema(brand.value, contact.value)
|
||||
useWebSiteSchema()
|
||||
useSiteNavigationSchema(navData.value?.header)
|
||||
|
||||
// 精选数据(computed 保证响应式 + null 安全)
|
||||
const featuredReviews = computed(() => reviews.value?.items?.slice(0, 3) || [])
|
||||
const allQuestions = computed(() => faq.value?.categories?.flatMap(c => c.questions) || [])
|
||||
const totalFAQ = computed(() => allQuestions.value.length)
|
||||
const featuredFAQ = computed(() => allQuestions.value.slice(0, 5))
|
||||
|
||||
function truncate(text, maxLen = 120) {
|
||||
if (text.length <= maxLen) return text
|
||||
// 在最大长度内找最后一个句子结尾(。,!?;)
|
||||
const slice = text.slice(0, maxLen)
|
||||
const lastBreak = Math.max(
|
||||
slice.lastIndexOf('。'),
|
||||
slice.lastIndexOf(','),
|
||||
slice.lastIndexOf('!'),
|
||||
slice.lastIndexOf('?'),
|
||||
slice.lastIndexOf(';'),
|
||||
)
|
||||
if (lastBreak > maxLen * 0.4) {
|
||||
return text.slice(0, lastBreak + 1) + '……'
|
||||
}
|
||||
return slice + '……'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 产品语义树 =====
|
||||
.product-tree-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
margin-top: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.product-tree-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
transition: box-shadow 0.35s cubic-bezier(0.23, 1, 0.32, 1), transform 0.35s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
&--days { border-top: 3px solid @color-primary; }
|
||||
&--people { border-top: 3px solid @color-accent; }
|
||||
&--season { border-top: 3px solid #3B82F6; }
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.product-tree-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: @border-radius-md;
|
||||
background: @color-bg-gray;
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-md;
|
||||
|
||||
.product-tree-card--people & { color: @color-accent; }
|
||||
.product-tree-card--season & { color: #3B82F6; }
|
||||
}
|
||||
|
||||
.product-tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
|
||||
li {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
|
||||
a {
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
text-decoration: none;
|
||||
transition: color @transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: @color-primary-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-tree-cta {
|
||||
text-align: center;
|
||||
margin-top: @space-2xl;
|
||||
}
|
||||
|
||||
// ===== 知识库入口 =====
|
||||
.knowledge-hub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-lg;
|
||||
margin-top: @space-xl;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.knowledge-hub-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
padding: @space-xl;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-3px);
|
||||
|
||||
.knowledge-hub-card__icon {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: @border-radius-md;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
transition: background @transition-base, color @transition-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== FAQ预览 =====
|
||||
.faq-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faq-preview-item {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
transition: box-shadow @transition-base;
|
||||
|
||||
&[open] {
|
||||
box-shadow: @shadow-card;
|
||||
|
||||
.faq-toggle-icon {
|
||||
&::after {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.faq-preview-q {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: @space-md;
|
||||
padding: @space-lg @space-xl;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::marker {
|
||||
display: none;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: @color-bg-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-q-text {
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.faq-toggle-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background: @color-text-muted;
|
||||
border-radius: 1px;
|
||||
transition: transform @transition-base;
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.faq-preview-a {
|
||||
padding: 0 @space-xl @space-xl;
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.faq-preview-more {
|
||||
text-align: center;
|
||||
margin-top: @space-2xl;
|
||||
}
|
||||
</style>
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="page-news">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="品牌动态、产品发布、活动公告,第一时间了解呼籁旅行最新资讯"
|
||||
:breadcrumbs="[{ text: '最新动态' }]"
|
||||
background-image="/images/seasons/autumn-hero.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="最新动态摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行最新动态:品牌公告、产品发布、活动资讯、行业合作等信息,第一时间了解呼伦贝尔亲子游最新资讯。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="newsData" class="section">
|
||||
<div class="container">
|
||||
|
||||
<!-- Category filter -->
|
||||
<div class="news-filter">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
:class="['filter-btn', { 'filter-btn--active': activeCategory === cat }]"
|
||||
@click="activeCategory = cat"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- News list -->
|
||||
<div class="news-list">
|
||||
<article
|
||||
v-for="article in filteredArticles"
|
||||
:key="article.id"
|
||||
class="news-card"
|
||||
>
|
||||
<div class="news-meta">
|
||||
<span :class="['news-cat', `news-cat--${article.category}`]">{{ article.category }}</span>
|
||||
<time class="news-date" :datetime="article.date">{{ formatDate(article.date) }}</time>
|
||||
</div>
|
||||
<h2 class="news-title">{{ article.title }}</h2>
|
||||
<p class="news-summary">{{ article.summary }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="news-cta">
|
||||
<h3>想了解更多?</h3>
|
||||
<p>草原的风,已经在等您了。</p>
|
||||
<div class="news-cta-links">
|
||||
<CommonInternalLink to="/products" text="查看2026旅行产品" />
|
||||
<CommonInternalLink to="/summer-camp" text="小蒙马夏令营报名" />
|
||||
<CommonInternalLink to="/contact" text="联系定制师" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: newsData } = await usePublicApi('news')
|
||||
|
||||
const seo = useSEO('news')
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '最新动态' },
|
||||
])
|
||||
|
||||
// NewsArticle JSON-LD for each article
|
||||
;(newsData.value?.articles || []).forEach((article) => {
|
||||
useHead({
|
||||
script: [{
|
||||
key: `news-article-${article.id}`,
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'NewsArticle',
|
||||
headline: article.title,
|
||||
description: article.summary,
|
||||
articleBody: article.content,
|
||||
datePublished: article.date + 'T00:00:00+08:00',
|
||||
dateModified: article.date + 'T00:00:00+08:00',
|
||||
articleSection: article.category,
|
||||
inLanguage: 'zh-CN',
|
||||
url: 'https://1814.love/news',
|
||||
author: { '@type': 'Organization', name: '呼籁旅行', url: 'https://1814.love' },
|
||||
publisher: { '@id': 'https://1814.love/#organization' },
|
||||
about: { '@type': 'TravelAgency', name: '呼籁旅行', url: 'https://1814.love' },
|
||||
keywords: '呼籁旅行,呼伦贝尔旅游,额吉的故乡,小蒙马夏令营',
|
||||
}),
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
const categories = ['全部', '产品', '活动', '公告']
|
||||
const activeCategory = ref('全部')
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
if (activeCategory.value === '全部') return newsData.value?.articles || []
|
||||
return (newsData.value?.articles || []).filter(a => a.category === activeCategory.value)
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.news-filter {
|
||||
display: flex;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-2xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: @space-sm @space-lg;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
background: @color-bg-white;
|
||||
color: @color-text-secondary;
|
||||
font-size: @font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: @color-primary;
|
||||
border-color: @color-primary;
|
||||
color: @color-bg-white;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.news-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-lg;
|
||||
margin-bottom: @space-3xl;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
padding: @space-xl;
|
||||
transition: box-shadow @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.news-cat {
|
||||
display: inline-block;
|
||||
padding: 2px @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
|
||||
&--产品 {
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&--活动 {
|
||||
background: #FFF3E6;
|
||||
color: @color-accent;
|
||||
}
|
||||
|
||||
&--公告 {
|
||||
background: @color-bg-gray;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
.news-summary {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.news-cta {
|
||||
text-align: center;
|
||||
padding: @space-2xl;
|
||||
background: @color-primary-bg;
|
||||
border-radius: @border-radius-lg;
|
||||
|
||||
h3 {
|
||||
font-size: @font-size-xl;
|
||||
margin-bottom: @space-sm;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
p {
|
||||
color: @color-text-secondary;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.news-cta-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: @space-xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="page-partners">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="行业认证 · 平台背书 · 景区合作 · 高校联合"
|
||||
:breadcrumbs="[{ text: '关于我们', to: '/about' }, { text: '合作伙伴' }]"
|
||||
background-image="/images/team/campsite-team.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="合作伙伴摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行合作伙伴:涵盖平台认证(携程、飞猪、马蜂窝)、景区合作、高校联合、异业合作等多个维度,展示品牌信任背书。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 平台认证(已验证,排在最前) -->
|
||||
<section v-if="partners" class="section section-platforms">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="平台认证与荣誉"
|
||||
subtitle="官方认证账号,平台公开可查"
|
||||
/>
|
||||
<div class="platforms-grid reveal">
|
||||
<div
|
||||
v-for="platform in verifiedPlatforms"
|
||||
:key="platform.platform"
|
||||
class="platform-card platform-card--verified"
|
||||
>
|
||||
<div class="platform-card__status">✓ {{ platform.status }}</div>
|
||||
<h3 class="platform-card__name">{{ platform.platform }}</h3>
|
||||
<p class="platform-card__detail">{{ platform.detail }}</p>
|
||||
<p v-if="platform.followers" class="platform-card__followers">粉丝 {{ platform.followers }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 政府与行业认证 -->
|
||||
<section v-if="partners" class="section section-gov section-alt">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="政府资质与行业背书"
|
||||
subtitle="来自官方机构的认可与合作"
|
||||
/>
|
||||
<div class="partners-list reveal">
|
||||
<div class="partners-list__group">
|
||||
<h3 class="partners-list__group-title">官方资质认定</h3>
|
||||
<div class="partners-items">
|
||||
<div v-for="item in partners.associations.slice(0, 1)" :key="item.name" class="partner-item partner-item--official">
|
||||
<div class="partner-item__icon">🏛️</div>
|
||||
<div class="partner-item__content">
|
||||
<h4 class="partner-item__name">{{ item.name }}</h4>
|
||||
<p class="partner-item__relation">{{ item.relationship }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="partners-list__group">
|
||||
<h3 class="partners-list__group-title">行业协会
|
||||
<span class="group-title-note">⚠️ 以下信息需确认</span>
|
||||
</h3>
|
||||
<div class="partners-items">
|
||||
<div
|
||||
v-for="item in partners.associations.slice(1)"
|
||||
:key="item.name"
|
||||
class="partner-item"
|
||||
:class="{ 'partner-item--unverified': !item.verified }"
|
||||
>
|
||||
<div class="partner-item__icon">🤝</div>
|
||||
<div class="partner-item__content">
|
||||
<h4 class="partner-item__name">{{ item.name }}</h4>
|
||||
<p class="partner-item__relation">{{ item.memberType }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 高校合作 -->
|
||||
<section v-if="partners" class="section section-academic">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="校企合作"
|
||||
subtitle="联合高校,培养本地旅游人才"
|
||||
/>
|
||||
<div class="academic-grid reveal">
|
||||
<div
|
||||
v-for="coop in partners.academicCoops"
|
||||
:key="coop.name"
|
||||
class="academic-card"
|
||||
>
|
||||
<div class="academic-card__icon">🎓</div>
|
||||
<div class="academic-card__content">
|
||||
<h3 class="academic-card__name">{{ coop.name }}</h3>
|
||||
<span class="academic-card__type">{{ coop.relationship }}</span>
|
||||
<p class="academic-card__desc">{{ coop.description }}</p>
|
||||
<span v-if="coop.since" class="academic-card__since">合作始于 {{ coop.since }} 年</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 景区合作 -->
|
||||
<section v-if="partners" class="section section-scenic section-alt">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="景区合作"
|
||||
subtitle="与呼伦贝尔核心景区深度合作,确保专属体验"
|
||||
/>
|
||||
<div class="scenic-grid reveal">
|
||||
<div
|
||||
v-for="scenic in verifiedScenicPartners"
|
||||
:key="scenic.name"
|
||||
class="scenic-card"
|
||||
>
|
||||
<h3 class="scenic-card__name">{{ scenic.name }}</h3>
|
||||
<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">
|
||||
<div class="container">
|
||||
<div class="section-header-with-note reveal">
|
||||
<CommonSectionTitle
|
||||
title="媒体报道"
|
||||
subtitle="第三方媒体对呼籁旅行的报道与采访"
|
||||
/>
|
||||
<div class="placeholder-banner">
|
||||
⚠️ 本栏目所有内容均为占位框架,需补充真实媒体报道链接后方可正式上线
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-grid reveal">
|
||||
<div
|
||||
v-for="(report, index) in partners.mediaReports"
|
||||
:key="index"
|
||||
class="media-card media-card--placeholder"
|
||||
>
|
||||
<div class="media-card__outlet">{{ report.outlet }}</div>
|
||||
<h3 class="media-card__title">{{ report.title }}</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内链 -->
|
||||
<CommonSectionCTA
|
||||
title="选择有资质的旅行品牌"
|
||||
description="查看呼籁的完整资质证明,或直接联系定制师"
|
||||
:links="[
|
||||
{ to: '/qualifications', text: '查看完整资质证书' },
|
||||
{ to: '/reviews', text: '查看真实客户评价' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOrganizationSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: partners } = await usePublicApi('partners')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
|
||||
const seo = useSEO('partners')
|
||||
useScrollReveal()
|
||||
useOrganizationSchema(brand.value, contact.value)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '关于我们', to: '/about' },
|
||||
{ text: '合作伙伴与媒体' },
|
||||
])
|
||||
|
||||
const verifiedPlatforms = computed(() =>
|
||||
(partners.value?.platformEndorsements || []).filter(p => p.verified)
|
||||
)
|
||||
|
||||
const verifiedScenicPartners = computed(() =>
|
||||
(partners.value?.scenicPartners || []).filter(p => p.verified)
|
||||
)
|
||||
|
||||
const unverifiedScenicCount = computed(() =>
|
||||
(partners.value?.scenicPartners || []).filter(p => !p.verified).length
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 平台认证
|
||||
.platforms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
|
||||
&--verified {
|
||||
border-color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0 0 @space-sm;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
&__followers {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 政府合作
|
||||
.section-gov {
|
||||
background: @color-bg-gray;
|
||||
}
|
||||
|
||||
.partners-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xl;
|
||||
|
||||
&__group {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-lg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.group-title-note {
|
||||
font-size: @font-size-xs;
|
||||
font-weight: normal;
|
||||
color: #e67e22;
|
||||
background: #fef9e7;
|
||||
padding: 2px @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
}
|
||||
|
||||
.partners-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.partner-item {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
align-items: flex-start;
|
||||
padding: @space-md;
|
||||
border-radius: @border-radius-md;
|
||||
background: @color-bg-gray;
|
||||
|
||||
&--official {
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&--unverified {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
|
||||
&__relation {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 高校合作
|
||||
.academic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.academic-card {
|
||||
display: flex;
|
||||
gap: @space-lg;
|
||||
align-items: flex-start;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
|
||||
&__icon {
|
||||
font-size: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
|
||||
&__type {
|
||||
display: inline-block;
|
||||
font-size: @font-size-xs;
|
||||
padding: 2px @space-sm;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-full;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0 0 @space-sm;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
&__since {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// 景区合作
|
||||
.section-scenic {
|
||||
background: @color-bg-gray;
|
||||
}
|
||||
|
||||
.scenic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-md;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.scenic-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
padding: @space-lg;
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
}
|
||||
|
||||
.section-placeholder-note {
|
||||
margin-top: @space-lg;
|
||||
text-align: center;
|
||||
font-size: @font-size-sm;
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
// 媒体报道
|
||||
.section-header-with-note {
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.placeholder-banner {
|
||||
background: #fef9e7;
|
||||
border: 1px solid #f39c12;
|
||||
border-radius: @border-radius-md;
|
||||
padding: @space-md @space-lg;
|
||||
font-size: @font-size-sm;
|
||||
color: #7d6608;
|
||||
margin-top: @space-lg;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.media-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
|
||||
&--placeholder {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__outlet {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0 0 @space-lg;
|
||||
line-height: @line-height-base;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: @space-sm;
|
||||
border-top: 1px solid @color-border;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__link-placeholder {
|
||||
font-size: @font-size-xs;
|
||||
color: #e67e22;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="page-pricing">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="不藏价格,不绕弯子"
|
||||
:breadcrumbs="[{ text: '价格参考' }]"
|
||||
background-image="/images/team/hero-grassland.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="价格参考摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔亲子游价格参考:根据出行天数和人数,每人每天800-1500元区间。全程无隐形消费,含正规电子合同和1000万旅游责任险。报价后2小时内回复。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 简介 -->
|
||||
<section v-if="pricing" class="section section-intro reveal">
|
||||
<div class="container">
|
||||
<div class="intro-box">
|
||||
<p class="intro-text">{{ pricing.intro }}</p>
|
||||
<div class="trust-badges">
|
||||
<span class="badge">✓ 全程无隐形消费</span>
|
||||
<span class="badge">✓ 正规电子合同</span>
|
||||
<span class="badge">✓ 1000万旅游责任险</span>
|
||||
<span class="badge">✓ 报价后2小时回复</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 产品价格卡片 -->
|
||||
<section class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="产品线价格参考"
|
||||
subtitle="6条定制游线路 + 1个亲子夏令营 + 秋季产品"
|
||||
/>
|
||||
<div class="pricing-grid">
|
||||
<div
|
||||
v-for="product in pricing.products"
|
||||
:key="product.id"
|
||||
class="pricing-card"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="card-tag">{{ product.tag }}</span>
|
||||
<h3 class="card-name">{{ product.name }}</h3>
|
||||
<p class="card-audience">适合:{{ product.audience }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-duration" v-if="product.days > 0">
|
||||
<span class="duration-text">{{ product.days }}天{{ product.nights }}晚</span>
|
||||
</div>
|
||||
<ul class="card-highlights">
|
||||
<li v-for="h in product.highlights" :key="h">{{ h }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="price-label">{{ product.priceLabel }}</div>
|
||||
<p class="price-note">{{ product.priceNote }}</p>
|
||||
<NuxtLink :to="product.url" class="card-link">查看产品详情 →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 费用包含/不含 -->
|
||||
<section v-if="pricing" class="section section-inclusions reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle
|
||||
title="费用说明"
|
||||
subtitle="所有产品遵循同一套费用标准"
|
||||
/>
|
||||
<div class="inclusions-grid">
|
||||
<div class="inclusion-col included">
|
||||
<h3 class="inclusion-title">
|
||||
<span class="inclusion-icon">✓</span> 费用包含
|
||||
</h3>
|
||||
<ul class="inclusion-list">
|
||||
<li v-for="item in pricing.included" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="inclusion-col excluded">
|
||||
<h3 class="inclusion-title">
|
||||
<span class="inclusion-icon">✗</span> 费用不含
|
||||
</h3>
|
||||
<ul class="inclusion-list">
|
||||
<li v-for="item in pricing.excluded" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 价格透明原则 -->
|
||||
<section v-if="pricing" class="section section-philosophy reveal">
|
||||
<div class="container">
|
||||
<div class="philosophy-box">
|
||||
<h2 class="philosophy-title">{{ pricing.philosophy.title }}</h2>
|
||||
<p class="philosophy-content">{{ pricing.philosophy.content }}</p>
|
||||
<p class="philosophy-promise">{{ pricing.philosophy.promise }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<CommonSectionCTA
|
||||
v-if="pricing"
|
||||
:title="pricing.cta.title"
|
||||
:description="pricing.cta.description"
|
||||
:links="[{ to: pricing.cta.to, text: pricing.cta.button }]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOrganizationSchema, useBreadcrumbSchema, usePricingSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: pricing } = await usePublicApi('pricing')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
|
||||
const seo = useSEO('pricing')
|
||||
useScrollReveal()
|
||||
useOrganizationSchema(brand.value, contact.value)
|
||||
usePricingSchema(pricing.value?.products)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '价格参考' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.intro-box {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.trust-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-md;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: @space-sm @space-lg;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
border: 1px solid @color-primary-lighter;
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: @space-xl;
|
||||
margin-top: @space-2xl;
|
||||
|
||||
@media (max-width: @breakpoint-lg) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: @breakpoint-md) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
background: @color-bg-white;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: @space-xl @space-xl @space-lg;
|
||||
background: @color-bg-gray;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
display: inline-block;
|
||||
padding: 2px @space-sm;
|
||||
background: @color-primary;
|
||||
color: @color-bg-white;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-sm;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
.card-audience {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
padding: @space-lg @space-xl;
|
||||
}
|
||||
|
||||
.card-duration {
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-accent;
|
||||
font-weight: @font-weight-medium;
|
||||
background: #FFF8F0;
|
||||
padding: 2px @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
}
|
||||
|
||||
.card-highlights {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
|
||||
li {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
padding-left: @space-md;
|
||||
position: relative;
|
||||
line-height: @line-height-base;
|
||||
|
||||
&::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: @space-lg @space-xl @space-xl;
|
||||
border-top: 1px solid @color-border;
|
||||
}
|
||||
|
||||
.price-label {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.price-note {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
line-height: @line-height-base;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
text-decoration: none;
|
||||
font-weight: @font-weight-medium;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.section-inclusions {
|
||||
background: @color-bg-gray;
|
||||
}
|
||||
|
||||
.inclusions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: @space-2xl;
|
||||
margin-top: @space-2xl;
|
||||
|
||||
@media (max-width: @breakpoint-md) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.inclusion-col {
|
||||
padding: @space-2xl;
|
||||
border-radius: @border-radius-lg;
|
||||
|
||||
&.included {
|
||||
background: @color-primary-bg;
|
||||
border: 1px solid @color-primary-lighter;
|
||||
}
|
||||
|
||||
&.excluded {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
}
|
||||
}
|
||||
|
||||
.inclusion-title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-lg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.inclusion-icon {
|
||||
.included & {
|
||||
color: @color-primary;
|
||||
}
|
||||
.excluded & {
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.inclusion-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
|
||||
li {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
padding-left: @space-md;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '—';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-philosophy {
|
||||
background: @color-bg-warm;
|
||||
}
|
||||
|
||||
.philosophy-box {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.philosophy-title {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
.philosophy-content {
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
.philosophy-promise {
|
||||
font-size: @font-size-base;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
line-height: @line-height-base;
|
||||
padding: @space-lg;
|
||||
background: @color-primary-bg;
|
||||
border-radius: @border-radius-md;
|
||||
border-left: 3px solid @color-primary;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<main class="product-detail" v-if="product">
|
||||
<section class="product-hero">
|
||||
<div class="container">
|
||||
<nav class="breadcrumb" aria-label="面包屑导航">
|
||||
<NuxtLink to="/">首页</NuxtLink> / <NuxtLink to="/products">产品线</NuxtLink> / <span>{{ product.name }}</span>
|
||||
</nav>
|
||||
<div class="hero-content">
|
||||
<span class="product-tag">{{ product.tag }}</span>
|
||||
<h1>{{ seo.h1 || product.name }}</h1>
|
||||
<p class="hero-audience">适合:{{ product.audience }}</p>
|
||||
<p class="hero-duration">{{ product.days }}天{{ product.nights }}晚 · 一家一单一车</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-description">
|
||||
<div class="container">
|
||||
<p>{{ product.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-highlights" v-if="product.highlights?.length">
|
||||
<div class="container">
|
||||
<h2>行程亮点</h2>
|
||||
<ul>
|
||||
<li v-for="(h, i) in product.highlights" :key="i">{{ h }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-faq" v-if="relatedFaq.length">
|
||||
<div class="container">
|
||||
<h2>常见问题</h2>
|
||||
<div class="faq-list">
|
||||
<details v-for="q in relatedFaq" :key="q.id" class="faq-item">
|
||||
<summary>{{ q.question }}</summary>
|
||||
<p>{{ q.answer }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-cta">
|
||||
<div class="container">
|
||||
<h2>开始定制您的行程</h2>
|
||||
<p>微信添加 hulai1814,定制师一对一为您规划</p>
|
||||
<NuxtLink to="/contact" class="cta-button">联系定制师</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useSEO from '~/composables/useSEO'
|
||||
import { useTouristTripSchema, useFAQPageSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const route = useRoute()
|
||||
const productId = route.params.id
|
||||
|
||||
const { data: productsData } = await usePublicApi('products')
|
||||
const { data: faqData } = await usePublicApi('faq')
|
||||
|
||||
const product = computed(() => productsData.value?.versions?.find(v => v.id === productId))
|
||||
|
||||
if (!product.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: '产品不存在' })
|
||||
}
|
||||
|
||||
// SEO - 每个产品独立的 meta
|
||||
const seoOverrides = {
|
||||
title: `${product.value.name} - 呼伦贝尔${product.value.days}天${product.value.nights}晚定制游 - 呼籁旅行`,
|
||||
description: product.value.description?.substring(0, 160),
|
||||
h1: product.value.name,
|
||||
}
|
||||
const seo = useSEO('products', seoOverrides)
|
||||
|
||||
// 产品页专属 keywords
|
||||
useHead({
|
||||
meta: [
|
||||
{ name: 'keywords', content: `${product.value.name},呼伦贝尔${product.value.days}天${product.value.nights}晚,呼伦贝尔定制游,呼伦贝尔亲子游,${product.value.tag}` },
|
||||
],
|
||||
})
|
||||
|
||||
// JSON-LD: 单个产品的 TouristTrip
|
||||
useTouristTripSchema([product.value])
|
||||
|
||||
// 面包屑
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '产品线', to: '/products' },
|
||||
{ text: product.value.name },
|
||||
])
|
||||
|
||||
// 相关 FAQ - 从 FAQ 数据中匹配与天数/产品相关的问题
|
||||
const allQuestions = computed(() => faqData.value?.categories?.flatMap(cat => cat.questions) || [])
|
||||
const relatedFaq = computed(() => allQuestions.value.filter(q =>
|
||||
q.question.includes(String(product.value?.days)) ||
|
||||
q.question.includes('几天') ||
|
||||
q.question.includes('亲子') ||
|
||||
q.question.includes('区别') ||
|
||||
q.question.includes('多少钱')
|
||||
).slice(0, 5))
|
||||
|
||||
// 产品页也注入 FAQ schema(SEO精选摘要加分项)
|
||||
if (relatedFaq.value.length) {
|
||||
useFAQPageSchema([{ questions: relatedFaq.value }])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.product-hero {
|
||||
background: linear-gradient(135deg, #f0f5f1, #e8ede9);
|
||||
padding: 40px 0 48px;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
color: #7a857c;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: #3a7d44;
|
||||
text-decoration: none;
|
||||
}
|
||||
.product-tag {
|
||||
display: inline-block;
|
||||
background: #3a7d44;
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #1a1f1c;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.hero-audience, .hero-duration {
|
||||
font-size: 16px;
|
||||
color: #4a5e4d;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.product-description {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.product-description p {
|
||||
font-size: 17px;
|
||||
line-height: 1.8;
|
||||
color: #3d4a40;
|
||||
}
|
||||
.product-highlights {
|
||||
padding: 40px 0;
|
||||
background: #fafbfa;
|
||||
}
|
||||
.product-highlights h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: #1a1f1c;
|
||||
}
|
||||
.product-highlights ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.product-highlights li {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #eef0ef;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #3d4a40;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.product-highlights li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #3a7d44;
|
||||
font-weight: 700;
|
||||
}
|
||||
.product-faq {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.product-faq h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: #1a1f1c;
|
||||
}
|
||||
.faq-item {
|
||||
border-bottom: 1px solid #eef0ef;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.faq-item summary {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1f1c;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.faq-item summary::marker { display: none; }
|
||||
.faq-item summary::-webkit-details-marker { display: none; }
|
||||
.faq-item p {
|
||||
margin-top: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: #4a5e4d;
|
||||
}
|
||||
.product-cta {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f0f5f1, #e8ede9);
|
||||
}
|
||||
.product-cta h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.product-cta p {
|
||||
color: #4a5e4d;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: #3a7d44;
|
||||
color: #fff;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.cta-button:hover {
|
||||
background: #2e6435;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="page-autumn">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
:subtitle="autumnData?.style"
|
||||
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '游牧的森林' }]"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="产品页摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>游牧的森林V3系列提供4-7天6个版本的呼伦贝尔秋季定制游,涵盖南线(呼伦贝尔+阿尔山)和北线(呼伦贝尔+大兴安岭)两条主线。所有版本均为独立成团、一家一单一车,不与陌生人拼团。不确定选哪条线,可<NuxtLink to="/contact">联系定制师</NuxtLink>获取推荐。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 风景大图 -->
|
||||
<div class="scenic-banner">
|
||||
<img src="/images/seasons/autumn-hero.jpg" alt="秋季大兴安岭金色白桦林" loading="eager" />
|
||||
</div>
|
||||
|
||||
<!-- 产品设计理念 -->
|
||||
<ProductsProductOverview v-if="autumnData" :narrative="autumnData.narrative" class="reveal" />
|
||||
|
||||
<!-- 秋季亮点 -->
|
||||
<section v-if="autumnData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="秋季呼伦贝尔 · 不可错过的风景" subtitle="每年9月中旬-10月中旬" />
|
||||
<div class="highlights-grid">
|
||||
<div v-for="h in autumnData.highlights" :key="h.title" class="highlight-card">
|
||||
<h3 class="highlight-title">{{ h.title }}</h3>
|
||||
<p class="highlight-desc">{{ h.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 南线产品卡片 -->
|
||||
<section class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="南线 · 呼伦贝尔+阿尔山" subtitle="草原秋色与火山天池" />
|
||||
<div class="products-grid">
|
||||
<AutumnProductCard v-for="p in southVersions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 南线特色体验 -->
|
||||
<section v-if="autumnData" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="南线独有体验" 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>
|
||||
<p class="highlight-desc">{{ h.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 中间转化入口 -->
|
||||
<CommonSectionCTA
|
||||
title="不确定选南线还是北线?"
|
||||
description="联系定制师,根据您的假期和偏好推荐最适合的行程"
|
||||
:links="[{ to: '/contact', text: '联系定制师' }]"
|
||||
/>
|
||||
|
||||
<!-- 北线产品卡片 -->
|
||||
<section class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="北线 · 呼伦贝尔+大兴安岭" subtitle="深入大兴安岭腹地赏秋" />
|
||||
<div class="products-grid">
|
||||
<AutumnProductCard v-for="p in northVersions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 北线特色体验 -->
|
||||
<section v-if="autumnData" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="北线独有体验" 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>
|
||||
<p class="highlight-desc">{{ h.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 选择指南 -->
|
||||
<section v-if="autumnData?.selectionGuide" id="guide" class="section section--gray reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="不知道选哪个?" subtitle="按假期长度推荐" />
|
||||
<div class="guide-items">
|
||||
<div v-for="item in autumnData.selectionGuide.byVacationLength" :key="item.condition" class="guide-item">
|
||||
<div class="guide-item-top">
|
||||
<span class="guide-condition">{{ item.condition }}</span>
|
||||
<span class="guide-arrow">→</span>
|
||||
<span class="guide-rec">{{ getProductName(item.recommendation) }}</span>
|
||||
</div>
|
||||
<p class="guide-reason">{{ item.reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 版本迭代时间线 -->
|
||||
<ProductsVersionTimeline
|
||||
v-if="autumnData"
|
||||
:timeline="autumnData.timeline"
|
||||
title="游牧的森林 · 版本迭代"
|
||||
subtitle="V1到V3的完整迭代记录"
|
||||
/>
|
||||
|
||||
<!-- 底部 CTA -->
|
||||
<CommonSectionCTA
|
||||
title="听听其他家庭怎么说"
|
||||
description="看看真实客户的旅行评价"
|
||||
:links="[{ to: '/reviews', text: '查看10000+家庭的真实评价' }]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: autumnData } = await usePublicApi('autumn-products')
|
||||
|
||||
const seo = useSEO('products-autumn', {
|
||||
title: '游牧的森林V3 - 呼伦贝尔秋季定制游 - 呼籁旅行',
|
||||
description: '呼籁旅行呼伦贝尔秋季定制游产品「游牧的森林」V3系列,南线呼伦贝尔+阿尔山、北线呼伦贝尔+大兴安岭,4-7天6个版本可选。独立成团不拼团,一家一单一车。',
|
||||
h1: '游牧的森林 · 呼伦贝尔秋季定制游',
|
||||
})
|
||||
|
||||
useScrollReveal()
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '产品线', to: '/products' },
|
||||
{ text: '游牧的森林' },
|
||||
])
|
||||
|
||||
const southVersions = computed(() =>
|
||||
autumnData.value?.versions?.filter(v => v.line !== '北线') || []
|
||||
)
|
||||
|
||||
const northVersions = computed(() =>
|
||||
autumnData.value?.versions?.filter(v => v.line === '北线') || []
|
||||
)
|
||||
|
||||
function getProductName(id) {
|
||||
const v = autumnData.value?.versions?.find(v => v.id === id)
|
||||
return v ? v.name : id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
|
||||
> * {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.scenic-banner {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
max-height: 480px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
object-fit: cover;
|
||||
object-position: center 70%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// 秋季亮点卡片
|
||||
.highlights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
|
||||
&--small {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
transition: box-shadow @transition-fast, transform @transition-fast;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-sm;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-sm;
|
||||
padding-left: @space-sm;
|
||||
border-left: 3px solid #D97706;
|
||||
}
|
||||
|
||||
.highlight-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 选择指南
|
||||
.guide-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-md;
|
||||
border: 1px solid @color-border;
|
||||
transition: border-color @transition-fast, box-shadow @transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: #D97706;
|
||||
box-shadow: @shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
.guide-condition {
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
background: @color-bg-gray;
|
||||
padding: @space-xs @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
.guide-arrow {
|
||||
color: #D97706;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
|
||||
.guide-rec {
|
||||
color: #D97706;
|
||||
font-weight: @font-weight-bold;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
.guide-reason {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="page-products">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景"
|
||||
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '额吉的故乡' }]"
|
||||
/>
|
||||
|
||||
<!-- TL;DR:告诉 AI/搜索引擎这个页面回答了什么 -->
|
||||
<section class="page-tldr" aria-label="产品页摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>额吉的故乡V9系列提供5-7天6个版本的呼伦贝尔亲子游,涵盖森林版、草原版、亲子版、探索版、旷野版等不同需求。所有版本均为独立成团、一家一单一车,不与陌生人拼团。不确定选哪个版本,可使用<NuxtLink to="/selector">选版工具</NuxtLink>进行1分钟匹配,或参考<NuxtLink to="/pricing">价格参考页</NuxtLink>了解费用区间。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 风景大图 -->
|
||||
<div class="scenic-banner">
|
||||
<img src="/images/seasons/summer-hero.jpg" alt="夏季呼伦贝尔莫日格勒河草原全景" loading="eager" />
|
||||
</div>
|
||||
|
||||
<!-- 产品设计理念 -->
|
||||
<ProductsProductOverview v-if="products" :narrative="products.narrative" class="reveal" />
|
||||
|
||||
<!-- 产品卡片 -->
|
||||
<section v-if="products" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="额吉的故乡V9系列" subtitle="5-7天多版本行程" />
|
||||
<div class="products-grid">
|
||||
<ProductsProductCard v-for="p in products.versions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 夏季风景 -->
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<!-- 中间转化入口 -->
|
||||
<CommonSectionCTA
|
||||
title="不确定选哪个版本?"
|
||||
description="5个问题,1分钟,找到最适合你家庭的行程版本"
|
||||
:links="[{ to: '/selector', text: '使用选版工具' }, { to: '/contact', text: '联系定制师' }]"
|
||||
/>
|
||||
|
||||
<!-- 选择指南 -->
|
||||
<ProductsSelectionGuide v-if="products" :guide="products.selectionGuide" :versions="products.versions" class="reveal" />
|
||||
|
||||
<!-- 产品进化:统计数据 -->
|
||||
<ProductsEvolutionStats v-if="versions" :stats="versions.stats" class="reveal" />
|
||||
|
||||
<!-- 2026核心升级 -->
|
||||
<ProductsVersionUpgrades v-if="versions" :upgrades="versions.upgrades2026" class="reveal" />
|
||||
|
||||
<!-- V9亮点 -->
|
||||
<ProductsVersionHighlights v-if="versions" :highlights="versions.highlights" class="reveal" />
|
||||
|
||||
<!-- V8→V9对比 -->
|
||||
<ProductsVersionCompare v-if="versions" :compare="versions.compareV8V9" class="reveal" />
|
||||
|
||||
<!-- 历史版本时间线 -->
|
||||
<ProductsVersionTimeline v-if="versions" :timeline="versions.timeline" />
|
||||
|
||||
<!-- 产品哲学 -->
|
||||
<ProductsVersionPhilosophy v-if="versions" :philosophy="versions.philosophy" :quote="versions.quote" />
|
||||
|
||||
<!-- 价格透明 -->
|
||||
<ProductsPricingPhilosophy v-if="products" :content="products.pricingPhilosophy" />
|
||||
|
||||
<!-- 爸妈也想来草原 -->
|
||||
<section class="section section--gray reveal">
|
||||
<div class="container" style="max-width: 720px; text-align: center;">
|
||||
<h2 class="section-title" style="font-size: 24px; margin-bottom: 8px;">爸妈也想来草原?</h2>
|
||||
<p style="color: #5a6b5e; line-height: 1.8; margin-bottom: 24px;">很多家庭带孩子来过呼伦贝尔之后,又给爸妈报了名。自己上班没时间陪,但想让父母也体验一次靠谱的草原旅行——我们的「牵手草原」长辈定制行程,专为父母和长辈设计,节奏更缓、住宿更舒适、服务更细致。</p>
|
||||
<NuxtLink to="/contact" class="senior-cta">联系定制师了解「牵手草原」</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内链 -->
|
||||
<CommonSectionCTA
|
||||
title="听听其他家庭怎么说"
|
||||
description="看看真实客户的旅行评价"
|
||||
:links="[{ to: '/reviews', text: '查看10000+家庭的真实评价' }]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useProductsListSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: products } = await usePublicApi('products')
|
||||
const { data: versions } = await usePublicApi('versions')
|
||||
|
||||
const seo = useSEO('products')
|
||||
useScrollReveal()
|
||||
useProductsListSchema(products.value?.versions)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '产品线', to: '/products' },
|
||||
{ text: '额吉的故乡' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
|
||||
// 让卡片等高对齐
|
||||
> * {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.scenic-banner {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
max-height: 480px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
object-fit: cover;
|
||||
object-position: center 70%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.scenic-duo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
max-height: 360px;
|
||||
overflow: hidden;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: 1fr 1fr;
|
||||
});
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
max-height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.senior-cta {
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
background: @color-bg-white;
|
||||
color: @color-primary;
|
||||
border: 1px solid @color-primary;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
transition: all @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="page-winter">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景"
|
||||
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '嗨冰雪' }]"
|
||||
background-image="/images/seasons/winter-hero.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="冬季产品摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>嗨冰雪V3系列:呼伦贝尔冬季定制游,5-7天南北两线可选。独立成团不拼团,一单一车随走随停。冰雪越野、雪地策马、冬季那达慕。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 品牌理念 -->
|
||||
<ProductsProductOverview v-if="winterData" :narrative="winterData.narrative" class="reveal" />
|
||||
|
||||
<!-- 产品卡片 -->
|
||||
<section v-if="winterData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="嗨冰雪V3系列" subtitle="5-7天南北两线,专属私家团" />
|
||||
<div class="products-grid">
|
||||
<WinterProductCard v-for="p in winterData.versions" :key="p.id" :product="p" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 选行程指南 -->
|
||||
<CommonSectionCTA
|
||||
title="不确定选南线还是北线?"
|
||||
description="告诉定制师您的假期和偏好,一分钟帮您选对行程"
|
||||
:links="[{ to: '/contact', text: '联系定制师选行程' }]"
|
||||
/>
|
||||
|
||||
<!-- 共同亮点 -->
|
||||
<section v-if="winterData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="冬季行程亮点" 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>
|
||||
<p class="highlight-desc">{{ h.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 南线特色 -->
|
||||
<section v-if="winterData" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="南线特色 · 呼伦贝尔+阿尔山" 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>
|
||||
<p class="highlight-desc">{{ h.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 北线特色 -->
|
||||
<section v-if="winterData" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="北线特色 · 呼伦贝尔+大兴安岭" 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>
|
||||
<p class="highlight-desc">{{ h.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内链 -->
|
||||
<CommonSectionCTA
|
||||
title="听听冬季旅行的家庭怎么说"
|
||||
description="看看真实客户的冬季旅行评价"
|
||||
:links="[{ to: '/reviews', text: '查看客户评价' }, { to: '/contact', text: '咨询冬季行程' }]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: winterData } = await usePublicApi('winter-products')
|
||||
|
||||
const seo = useSEO('products-winter', {
|
||||
title: '嗨冰雪 - 呼伦贝尔冬季定制游 · 南北两线5-7天 - 呼籁旅行',
|
||||
description: '呼籁旅行冬季定制游产品「嗨冰雪V3」,南线呼伦贝尔+阿尔山5-6天,北线呼伦贝尔+大兴安岭7天。冰雪那达慕、雪地狼群、白狼峰日出、呼伦湖蓝冰、驯鹿部落。一单一车专属私家团。',
|
||||
h1: '嗨冰雪 · 呼伦贝尔冬季定制游',
|
||||
})
|
||||
useScrollReveal()
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '产品线', to: '/products' },
|
||||
{ text: '嗨冰雪' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
|
||||
> * {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.highlights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-sm;
|
||||
|
||||
&::before {
|
||||
content: '❄ ';
|
||||
color: #3B82F6;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
.section--warm {
|
||||
background: @color-bg-warm;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="page-qualifications">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '资质荣誉'"
|
||||
subtitle="三重旅行社资质 · 1000万责任险 · 行业认证"
|
||||
:breadcrumbs="[{ text: '资质荣誉' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="资质荣誉摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行资质荣誉:持有三重旅行社经营许可证,购买1000万旅游责任险,荣获小红书年度成长型品牌等行业认证。所有资质均可在线验证。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 服务保障 -->
|
||||
<section v-if="about" class="section" aria-label="服务保障">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="服务保障" subtitle="三重旅行社资质 · 1000万责任险 · 正规电子合同 · 全程无购物" />
|
||||
|
||||
<!-- 保障要点 -->
|
||||
<div class="guarantee-grid">
|
||||
<div v-for="g in about.guarantees" :key="g.title" class="guarantee-card">
|
||||
<h3 class="guarantee-card__title">{{ g.title }}</h3>
|
||||
<p class="guarantee-card__detail">{{ g.detail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 许可证详情 -->
|
||||
<h3 class="sub-heading">旅行社经营许可证</h3>
|
||||
<div class="licenses-grid">
|
||||
<div v-for="license in about.licenses" :key="license.licenseNo" class="license-card">
|
||||
<div class="license-card__badge">旅游经营许可证</div>
|
||||
<h4 class="license-card__holder">{{ license.holder }}</h4>
|
||||
<div class="license-card__info">
|
||||
<div class="license-row">
|
||||
<span class="license-label">颁发机构</span>
|
||||
<span class="license-value">{{ license.issuer }}</span>
|
||||
</div>
|
||||
<div class="license-row">
|
||||
<span class="license-label">许可证号</span>
|
||||
<span class="license-value license-value--mono">{{ license.licenseNo }}</span>
|
||||
</div>
|
||||
<div class="license-row">
|
||||
<span class="license-label">状态</span>
|
||||
<span class="license-value license-value--valid">{{ license.validPeriod }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 旅游责任险 -->
|
||||
<div class="insurance-block">
|
||||
<div class="insurance-icon">🛡️</div>
|
||||
<div class="insurance-body">
|
||||
<h3 class="insurance-title">{{ about.insurance.title }}</h3>
|
||||
<p class="insurance-desc">{{ about.insurance.description }}</p>
|
||||
<div class="insurance-details">
|
||||
<div class="insurance-detail">
|
||||
<span class="insurance-detail__label">承保方</span>
|
||||
<span class="insurance-detail__value">{{ about.insurance.insurer }}</span>
|
||||
</div>
|
||||
<div class="insurance-detail">
|
||||
<span class="insurance-detail__label">覆盖范围</span>
|
||||
<span class="insurance-detail__value">{{ about.insurance.policyScope }}</span>
|
||||
</div>
|
||||
<div class="insurance-detail">
|
||||
<span class="insurance-detail__label">理赔支持</span>
|
||||
<span class="insurance-detail__value">{{ about.insurance.claimContact }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 荣誉认证 -->
|
||||
<section v-if="about" class="section section--green" aria-label="荣誉与认证">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="荣誉与认证" 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>
|
||||
<h3 class="award-card__title">{{ award.title }}</h3>
|
||||
<p class="award-card__detail">{{ award.detail }}</p>
|
||||
<span class="award-card__issuer">{{ award.issuer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<CommonSectionCTA
|
||||
title="想了解我们的产品?"
|
||||
description="查看额吉的故乡V9系列和小蒙马夏令营"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看旅行产品' },
|
||||
{ to: '/contact', text: '联系定制师' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOrganizationSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: about } = await usePublicApi('about')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
|
||||
const seo = useSEO('qualifications')
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '资质荣誉' },
|
||||
])
|
||||
useOrganizationSchema(brand.value, contact.value)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 服务保障 =====
|
||||
.sub-heading {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: @space-2xl 0 @space-lg;
|
||||
}
|
||||
|
||||
.guarantee-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-md;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.guarantee-card {
|
||||
text-align: center;
|
||||
padding: @space-lg;
|
||||
background: @color-primary-bg;
|
||||
border-radius: @border-radius-lg;
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
margin: 0;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
}
|
||||
|
||||
.licenses-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.license-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
box-shadow: @shadow-card;
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: @space-xs @space-md;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
border-radius: @border-radius-full;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
&__holder {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-lg;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.license-row {
|
||||
display: flex;
|
||||
gap: @space-sm;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
.license-label {
|
||||
color: @color-text-muted;
|
||||
min-width: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.license-value {
|
||||
color: @color-text-secondary;
|
||||
|
||||
&--mono {
|
||||
font-family: monospace;
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&--valid {
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 责任险 =====
|
||||
.insurance-block {
|
||||
display: flex;
|
||||
gap: @space-xl;
|
||||
align-items: flex-start;
|
||||
background: @color-bg-white;
|
||||
border: 2px solid @color-primary-lighter;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-2xl;
|
||||
box-shadow: @shadow-card;
|
||||
margin-top: @space-2xl;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: @space-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.insurance-icon {
|
||||
font-size: 48px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.insurance-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.insurance-title {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
.insurance-desc {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
margin: 0 0 @space-lg;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
|
||||
.insurance-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.insurance-detail {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
font-size: @font-size-sm;
|
||||
|
||||
&__label {
|
||||
color: @color-text-muted;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 荣誉认证 =====
|
||||
.awards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.award-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
text-align: center;
|
||||
box-shadow: @shadow-card;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-card-hover;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
&__year {
|
||||
display: inline-block;
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
line-height: 1;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
line-height: @line-height-tight;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0 0 @space-md;
|
||||
}
|
||||
|
||||
&__issuer {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
padding: @space-xs @space-md;
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="page-reviews">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="来自真实家庭的旅行评价"
|
||||
:breadcrumbs="[{ text: '客户评价' }]"
|
||||
background-image="/images/summer-camp/camp-graduation.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR:告诉 AI/搜索引擎这个页面回答了什么 -->
|
||||
<section class="page-tldr" aria-label="客户评价摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行已服务超10000个家庭,本页收录来自真实客户的旅行评价,均为未经修改的原始反馈。可按出行场景(亲子、摄影、蜜月等)和关注维度(安全性、性价比、服务质量等)筛选,找到与自己情况最相似的参考评价。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 数据汇总 -->
|
||||
<ReviewsReviewSummary v-if="reviews" :summary="reviews.summary" class="reveal" />
|
||||
|
||||
<!-- 筛选+列表 -->
|
||||
<section v-if="reviews" class="section">
|
||||
<div class="container">
|
||||
<!-- sticky 筛选栏:滚动时收起为紧凑模式 -->
|
||||
<div class="filter-sticky" :class="{ 'is-compact': isCompact && !filterExpanded }">
|
||||
<!-- 紧凑模式:一行摘要 -->
|
||||
<div v-if="isCompact && !filterExpanded" class="filter-compact" @click="filterExpanded = true">
|
||||
<div class="filter-compact-info">
|
||||
<span class="filter-compact-label">筛选</span>
|
||||
<span v-if="activeScene" class="filter-compact-tag">{{ activeScene }}</span>
|
||||
<span v-if="activeConcern" class="filter-compact-tag">{{ activeConcern }}</span>
|
||||
<span v-if="!activeScene && !activeConcern" class="filter-compact-hint">全部评价</span>
|
||||
<span class="filter-compact-count">{{ filteredReviews.length }} 条</span>
|
||||
</div>
|
||||
<span class="filter-compact-toggle">展开筛选 ▾</span>
|
||||
</div>
|
||||
|
||||
<!-- 完整模式 -->
|
||||
<div v-else>
|
||||
<ReviewsReviewFilter
|
||||
:scenes="allScenes"
|
||||
:concerns="allConcerns"
|
||||
:active-scene="activeScene"
|
||||
:active-concern="activeConcern"
|
||||
@filter-scene="activeScene = $event"
|
||||
@filter-concern="activeConcern = $event"
|
||||
/>
|
||||
<div class="filter-bottom">
|
||||
<span class="filter-count">
|
||||
<template v-if="activeScene || activeConcern">
|
||||
筛选结果:<strong>{{ filteredReviews.length }}</strong> 条评价
|
||||
</template>
|
||||
<template v-else>
|
||||
共 <strong>{{ reviews.items.length }}</strong> 条评价
|
||||
</template>
|
||||
</span>
|
||||
<button v-if="isCompact" class="filter-collapse-btn" @click="filterExpanded = false">
|
||||
收起 ▴
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewsReviewList :reviews="filteredReviews" @reset="activeScene = ''; activeConcern = ''" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内链 -->
|
||||
<CommonSectionCTA
|
||||
title="准备好了吗?"
|
||||
description="联系定制师,开始规划您的呼伦贝尔之旅"
|
||||
:links="[{ to: '/contact', text: '联系定制师,获取专属行程方案' }]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useBreadcrumbSchema, useAggregateRatingSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: reviews } = await usePublicApi('reviews')
|
||||
|
||||
const seo = useSEO('reviews')
|
||||
useScrollReveal()
|
||||
useAggregateRatingSchema(reviews.value?.summary, reviews.value?.items)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '客户评价' },
|
||||
])
|
||||
|
||||
const activeScene = ref('')
|
||||
const activeConcern = ref('')
|
||||
const filterExpanded = ref(false)
|
||||
const isCompact = ref(false)
|
||||
|
||||
const allScenes = computed(() => [...new Set((reviews.value?.items || []).flatMap(r => r.scenes))])
|
||||
const allConcerns = computed(() => [...new Set((reviews.value?.items || []).flatMap(r => r.concerns))])
|
||||
|
||||
const filteredReviews = computed(() => {
|
||||
return (reviews.value?.items || []).filter(r => {
|
||||
if (activeScene.value && !r.scenes.includes(activeScene.value)) return false
|
||||
if (activeConcern.value && !r.concerns.includes(activeConcern.value)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 监听滚动:向下滚过 200px 后进入紧凑模式
|
||||
onMounted(() => {
|
||||
const onScroll = () => {
|
||||
isCompact.value = window.scrollY > 200
|
||||
// 切回紧凑时自动收起
|
||||
if (!isCompact.value) {
|
||||
filterExpanded.value = false
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
onUnmounted(() => window.removeEventListener('scroll', onScroll))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.filter-sticky {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 10;
|
||||
background: @color-bg-white;
|
||||
padding-top: @space-sm;
|
||||
padding-bottom: @space-sm;
|
||||
margin-bottom: @space-lg;
|
||||
transition: box-shadow @transition-fast;
|
||||
|
||||
.respond-lg({
|
||||
top: 72px;
|
||||
});
|
||||
|
||||
&.is-compact {
|
||||
box-shadow: @shadow-sm;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
}
|
||||
|
||||
// 紧凑模式:单行
|
||||
.filter-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: @space-sm @space-md;
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-md;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
transition: background @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-compact-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-compact-label {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
.filter-compact-tag {
|
||||
padding: @space-xs @space-sm;
|
||||
font-size: @font-size-xs;
|
||||
color: #fff;
|
||||
background: @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
}
|
||||
|
||||
.filter-compact-hint {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.filter-compact-count {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
.filter-compact-toggle {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-primary;
|
||||
white-space: nowrap;
|
||||
margin-left: @space-md;
|
||||
}
|
||||
|
||||
// 完整模式底部
|
||||
.filter-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: @space-lg;
|
||||
padding-top: @space-sm;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
|
||||
strong {
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-collapse-btn {
|
||||
padding: @space-xs @space-md;
|
||||
font-size: @font-size-xs;
|
||||
color: @color-primary;
|
||||
background: none;
|
||||
border: 1px solid @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
cursor: pointer;
|
||||
transition: all @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+820
@@ -0,0 +1,820 @@
|
||||
<template>
|
||||
<div class="page-selector">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="5个问题 · 1分钟 · 找到最适合你的版本"
|
||||
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '选版工具' }]"
|
||||
background-image="/images/seasons/summer-hero.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="选版工具摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼伦贝尔行程选版工具:回答5个简单问题,1分钟内为你推荐最适合的产品版本。根据出行天数、人群、偏好智能匹配。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="selectorData" class="selector-section">
|
||||
<div class="container">
|
||||
<div class="selector-wrap">
|
||||
|
||||
<!-- 问答流程 -->
|
||||
<template v-if="!showResult">
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" :style="{ width: progressPercent + '%' }" />
|
||||
</div>
|
||||
<span class="progress-label">{{ currentStepIndex + 1 }} / {{ visibleQuestions.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 问题卡片 -->
|
||||
<Transition name="step" mode="out-in">
|
||||
<div :key="currentQuestion.id" class="question-card">
|
||||
<p class="question-hint">{{ currentQuestion.hint }}</p>
|
||||
<h2 class="question-text">{{ currentQuestion.text }}</h2>
|
||||
<div class="options-grid" :class="{ 'options-grid--3': currentQuestion.options.length === 3 }">
|
||||
<button
|
||||
v-for="opt in currentQuestion.options"
|
||||
:key="opt.value"
|
||||
class="option-btn"
|
||||
:class="{ 'option-btn--selected': answers[currentQuestion.id] === opt.value }"
|
||||
@click="selectAnswer(currentQuestion.id, opt.value)"
|
||||
>
|
||||
<span class="option-label">{{ opt.label }}</span>
|
||||
<span class="option-desc">{{ opt.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 导航按钮(非第一步才显示返回) -->
|
||||
<div class="step-nav">
|
||||
<button v-if="currentStepIndex > 0" class="btn-back" @click="goBack">
|
||||
← 上一步
|
||||
</button>
|
||||
<span v-else />
|
||||
<button
|
||||
v-if="answers[currentQuestion.id]"
|
||||
class="btn-next"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ isLastQuestion ? '查看推荐' : '下一步 →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- 推荐结果 -->
|
||||
<Transition name="step" mode="out-in">
|
||||
<div v-if="showResult" key="result" class="result-wrap">
|
||||
<div class="result-header">
|
||||
<p class="result-eyebrow">根据你的回答,我们推荐</p>
|
||||
<div class="result-tag">{{ recommended.tag }}</div>
|
||||
<h2 class="result-name">{{ recommended.name }}</h2>
|
||||
<p class="result-audience">{{ recommended.audience }}</p>
|
||||
</div>
|
||||
|
||||
<p class="result-desc">{{ recommended.description }}</p>
|
||||
|
||||
<!-- 推荐理由 -->
|
||||
<div class="match-reasons">
|
||||
<h3 class="match-reasons-title">为什么推荐这个版本</h3>
|
||||
<ul class="match-reasons-list">
|
||||
<li v-for="(reason, i) in matchReasons" :key="i" class="match-reason-item">
|
||||
<span class="match-reason-icon">✓</span>
|
||||
<span>{{ reason }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 产品亮点 -->
|
||||
<div class="result-highlights">
|
||||
<h3 class="result-highlights-title">行程亮点</h3>
|
||||
<ul class="highlights-list">
|
||||
<li v-for="(h, i) in recommended.highlights" :key="i" class="highlight-item">
|
||||
<span class="highlight-dot" />
|
||||
<span>{{ h }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="result-actions">
|
||||
<NuxtLink :to="`/products/${recommended.id}`" class="btn-primary">
|
||||
查看详细行程
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/customize" class="btn-cta">
|
||||
在线定制这条行程
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 换一个 -->
|
||||
<div class="alternate-wrap">
|
||||
<p class="alternate-label">不太满意?</p>
|
||||
<button class="btn-alternate" @click="showAlternate">
|
||||
换一个版本看看 →
|
||||
</button>
|
||||
<button class="btn-restart" @click="restart">
|
||||
重新回答
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 备选结果展示 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="alternateIndex > 0 && rankedProducts[alternateIndex]" class="alternate-result">
|
||||
<p class="alternate-eyebrow">备选推荐</p>
|
||||
<div class="alternate-tag">{{ rankedProducts[alternateIndex].tag }}</div>
|
||||
<h3 class="alternate-name">{{ rankedProducts[alternateIndex].name }}</h3>
|
||||
<p class="alternate-desc">{{ rankedProducts[alternateIndex].description }}</p>
|
||||
<div class="alternate-actions">
|
||||
<NuxtLink :to="`/products/${rankedProducts[alternateIndex].id}`" class="btn-outline">
|
||||
查看这个版本
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 全系列入口 -->
|
||||
<div class="all-versions">
|
||||
<p>想自己对比所有版本?</p>
|
||||
<NuxtLink to="/products" class="btn-link">查看全系列6个版本 →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部CTA -->
|
||||
<CommonSectionCTA
|
||||
title="还有疑问?直接问定制师"
|
||||
description="告诉我们你的家庭情况,定制师为你1对1选版本、排行程"
|
||||
:links="[{ to: '/customize', text: '在线定制' }, { to: '/contact', text: '联系定制师' }]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: selectorData } = await usePublicApi('selector')
|
||||
const { data: productsData } = await usePublicApi('products')
|
||||
|
||||
const seo = useSEO('selector')
|
||||
useScrollReveal()
|
||||
|
||||
// JSON-LD: Quiz + ItemList schema
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Quiz',
|
||||
name: '呼伦贝尔行程版本选择工具',
|
||||
description: '5个问题帮你找到最适合的呼伦贝尔行程版本。根据出行天数、人群、孩子年龄、预算和偏好,从额吉的故乡V9系列6个版本中精准推荐。',
|
||||
url: 'https://1814.love/selector',
|
||||
about: {
|
||||
'@type': 'TouristTrip',
|
||||
name: '呼伦贝尔家庭定制游',
|
||||
touristType: '家庭亲子游客',
|
||||
},
|
||||
numberOfQuestions: 5,
|
||||
provider: {
|
||||
'@type': 'TravelAgency',
|
||||
name: '呼籁旅行',
|
||||
url: 'https://1814.love',
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: '额吉的故乡V9系列行程版本',
|
||||
description: '呼籁旅行呼伦贝尔亲子游6个版本,5天4晚至7天6晚,覆盖森林版、草原版、亲子版、探索版、旷野版',
|
||||
url: 'https://1814.love/selector',
|
||||
numberOfItems: productsData.value?.versions?.length || 0,
|
||||
itemListElement: (productsData.value?.versions || []).map((v, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: v.name,
|
||||
description: v.audience,
|
||||
url: `https://1814.love/products/${v.id}`,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '产品线', to: '/products' },
|
||||
{ text: '帮我选行程' },
|
||||
])
|
||||
|
||||
const questions = computed(() => selectorData.value?.questions || [])
|
||||
const scoring = computed(() => selectorData.value?.scoring || {})
|
||||
const reasonsMap = computed(() => selectorData.value?.matchReasons || {})
|
||||
const allProducts = computed(() => productsData.value?.versions || [])
|
||||
|
||||
// 状态
|
||||
const answers = ref({})
|
||||
const currentStepIndex = ref(0)
|
||||
const showResult = ref(false)
|
||||
const alternateIndex = ref(0)
|
||||
|
||||
// 过滤出当前应显示的问题(根据条件)
|
||||
const visibleQuestions = computed(() => {
|
||||
return questions.value.filter((q) => {
|
||||
if (!q.condition) return true
|
||||
return answers.value[q.condition.key] === q.condition.value
|
||||
})
|
||||
})
|
||||
|
||||
const currentQuestion = computed(() => visibleQuestions.value[currentStepIndex.value])
|
||||
const isLastQuestion = computed(() => currentStepIndex.value === visibleQuestions.value.length - 1)
|
||||
const progressPercent = computed(() =>
|
||||
Math.round(((currentStepIndex.value) / visibleQuestions.value.length) * 100)
|
||||
)
|
||||
|
||||
// 计分逻辑
|
||||
const scores = computed(() => {
|
||||
const totals = {}
|
||||
for (const [questionId, questionScores] of Object.entries(scoring.value)) {
|
||||
const answer = answers.value[questionId]
|
||||
if (!answer) continue
|
||||
const productScores = questionScores[answer] || {}
|
||||
for (const [productId, score] of Object.entries(productScores)) {
|
||||
totals[productId] = (totals[productId] || 0) + score
|
||||
}
|
||||
}
|
||||
return totals
|
||||
})
|
||||
|
||||
// 按得分排序
|
||||
const rankedProducts = computed(() => {
|
||||
return [...allProducts.value].sort(
|
||||
(a, b) => (scores.value[b.id] || 0) - (scores.value[a.id] || 0)
|
||||
)
|
||||
})
|
||||
|
||||
const recommended = computed(() => rankedProducts.value[0])
|
||||
|
||||
// 生成匹配原因
|
||||
const matchReasons = computed(() => {
|
||||
if (!recommended.value) return []
|
||||
const pid = recommended.value.id
|
||||
const reasonMap = reasonsMap.value[pid] || {}
|
||||
const result = []
|
||||
|
||||
// 找匹配答案的原因
|
||||
for (const [qid, answer] of Object.entries(answers.value)) {
|
||||
const key = `${qid}:${answer}`
|
||||
if (reasonMap[key] && result.length < 3) {
|
||||
result.push(reasonMap[key])
|
||||
}
|
||||
}
|
||||
|
||||
// 补充默认原因
|
||||
if (result.length === 0 && reasonMap.default) {
|
||||
result.push(reasonMap.default)
|
||||
}
|
||||
|
||||
return result.slice(0, 3)
|
||||
})
|
||||
|
||||
// 操作方法
|
||||
function selectAnswer(questionId, value) {
|
||||
answers.value = { ...answers.value, [questionId]: value }
|
||||
// 移动端自动前进
|
||||
if (window.innerWidth < 768) {
|
||||
setTimeout(() => goNext(), 200)
|
||||
}
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (isLastQuestion.value) {
|
||||
showResult.value = true
|
||||
} else {
|
||||
currentStepIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (currentStepIndex.value > 0) {
|
||||
currentStepIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
function showAlternate() {
|
||||
if (alternateIndex.value < rankedProducts.value.length - 1) {
|
||||
alternateIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
function restart() {
|
||||
answers.value = {}
|
||||
currentStepIndex.value = 0
|
||||
showResult.value = false
|
||||
alternateIndex.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.selector-section {
|
||||
padding: @space-3xl 0;
|
||||
background: @color-bg-warm;
|
||||
}
|
||||
|
||||
.selector-wrap {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// 进度条
|
||||
.progress-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-2xl;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: @color-border;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: @color-primary;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 问题卡片
|
||||
.question-card {
|
||||
background: @color-bg-white;
|
||||
border-radius: 16px;
|
||||
padding: @space-2xl;
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.respond-md({
|
||||
padding: @space-3xl;
|
||||
});
|
||||
}
|
||||
|
||||
.question-hint {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-xl;
|
||||
line-height: @line-height-tight;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
// 选项网格
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
&--3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: @space-xs;
|
||||
padding: @space-md @space-lg;
|
||||
background: @color-bg-gray;
|
||||
border: 2px solid @color-border;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all @transition-base;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary-light;
|
||||
background: @color-primary-lighter;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: @color-primary;
|
||||
background: @color-primary-lighter;
|
||||
|
||||
.option-label {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
transition: color @transition-base;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
line-height: @line-height-normal;
|
||||
}
|
||||
|
||||
// 步骤导航
|
||||
.step-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: @space-sm 0;
|
||||
transition: color @transition-base;
|
||||
|
||||
&:hover {
|
||||
color: @color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-next {
|
||||
background: @color-primary;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: @space-sm @space-xl;
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: background @transition-base;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
// 结果区域
|
||||
.result-wrap {
|
||||
background: @color-bg-white;
|
||||
border-radius: 16px;
|
||||
padding: @space-2xl;
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.respond-md({
|
||||
padding: @space-3xl;
|
||||
});
|
||||
}
|
||||
|
||||
.result-header {
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.result-eyebrow {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
.result-tag {
|
||||
display: inline-block;
|
||||
background: @color-primary;
|
||||
color: white;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-xs;
|
||||
line-height: @line-height-tight;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-3xl;
|
||||
});
|
||||
}
|
||||
|
||||
.result-audience {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-desc {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin: 0 0 @space-xl;
|
||||
padding-bottom: @space-xl;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
|
||||
// 匹配原因
|
||||
.match-reasons {
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.match-reasons-title,
|
||||
.result-highlights-title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-md;
|
||||
}
|
||||
|
||||
.match-reasons-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.match-reason-item {
|
||||
display: flex;
|
||||
gap: @space-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-normal;
|
||||
}
|
||||
|
||||
.match-reason-icon {
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 亮点
|
||||
.result-highlights {
|
||||
margin-bottom: @space-xl;
|
||||
padding-bottom: @space-xl;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
|
||||
.highlights-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.highlight-item {
|
||||
display: flex;
|
||||
gap: @space-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-normal;
|
||||
}
|
||||
|
||||
.highlight-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: @color-accent;
|
||||
flex-shrink: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
.result-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
flex-direction: row;
|
||||
});
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: @color-bg-white;
|
||||
color: @color-primary;
|
||||
border: 2px solid @color-primary;
|
||||
border-radius: 8px;
|
||||
padding: @space-md @space-xl;
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-medium;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
background: @color-primary;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: @space-md @space-xl;
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-medium;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: background @transition-base;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
// 换一个
|
||||
.alternate-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.alternate-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-alternate {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color @transition-base;
|
||||
|
||||
&:hover {
|
||||
color: @color-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-restart {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color @transition-base;
|
||||
|
||||
&:hover {
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// 备选结果
|
||||
.alternate-result {
|
||||
background: @color-bg-gray;
|
||||
border-radius: 12px;
|
||||
padding: @space-lg;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.alternate-eyebrow {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
|
||||
.alternate-tag {
|
||||
display: inline-block;
|
||||
background: @color-accent;
|
||||
color: white;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
.alternate-name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
.alternate-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-normal;
|
||||
margin: 0 0 @space-md;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alternate-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: @color-primary;
|
||||
border: 1px solid @color-primary;
|
||||
border-radius: 6px;
|
||||
padding: @space-sm @space-lg;
|
||||
font-size: @font-size-sm;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
// 全系列入口
|
||||
.all-versions {
|
||||
text-align: center;
|
||||
padding-top: @space-lg;
|
||||
border-top: 1px solid @color-border;
|
||||
|
||||
p {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0 0 @space-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.step-enter-active,
|
||||
.step-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.step-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.step-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,519 @@
|
||||
<template>
|
||||
<div v-if="story" class="page-story-detail">
|
||||
<LayoutPageHero
|
||||
:title="story.title"
|
||||
:subtitle="story.subtitle"
|
||||
:breadcrumbs="[{ text: '客户故事', to: '/stories' }, { text: story.familyType }]"
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="story-layout">
|
||||
<!-- 主内容 -->
|
||||
<main class="story-main">
|
||||
<!-- 故事元信息 -->
|
||||
<div class="story-meta">
|
||||
<span class="story-meta__family">{{ story.familyType }}</span>
|
||||
<span class="story-meta__children">孩子 {{ story.childrenAge }}</span>
|
||||
<span v-if="story.fromCity !== '⚠️待填'" class="story-meta__city">来自 {{ story.fromCity }}</span>
|
||||
<span class="story-meta__date">{{ story.travelDate }}</span>
|
||||
<span class="story-meta__product">{{ story.tripProduct }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 配图建议 -->
|
||||
<div class="story-image-placeholder">
|
||||
<div class="story-image-placeholder__inner">
|
||||
<span class="story-image-placeholder__icon">📷</span>
|
||||
<p class="story-image-placeholder__hint">{{ story.imageHints }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 故事摘要 -->
|
||||
<div class="story-summary">
|
||||
<p>{{ story.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 故事章节 -->
|
||||
<div class="story-sections">
|
||||
<div
|
||||
v-for="(section, index) in story.sections"
|
||||
:key="index"
|
||||
class="story-section"
|
||||
>
|
||||
<h2 class="story-section__heading">{{ section.heading }}</h2>
|
||||
<p class="story-section__content">{{ section.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 印象深刻的瞬间 -->
|
||||
<div class="story-moments">
|
||||
<h2 class="story-moments__title">印象深刻的瞬间</h2>
|
||||
<div class="story-moments__list">
|
||||
<div
|
||||
v-for="(moment, index) in story.keyMoments"
|
||||
:key="index"
|
||||
class="moment-item"
|
||||
>
|
||||
<span class="moment-item__icon">✨</span>
|
||||
<p class="moment-item__text">{{ moment }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="story-tags">
|
||||
<span class="story-tags__label">标签:</span>
|
||||
<span v-for="tag in story.tags" :key="tag" class="story-tag">{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 前后导航 -->
|
||||
<div class="story-nav">
|
||||
<NuxtLink
|
||||
v-if="prevStory"
|
||||
:to="`/stories/${prevStory.id}`"
|
||||
class="story-nav__item story-nav__item--prev"
|
||||
>
|
||||
<span class="story-nav__label">上一个故事</span>
|
||||
<span class="story-nav__title">{{ prevStory.title }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="nextStory"
|
||||
:to="`/stories/${nextStory.id}`"
|
||||
class="story-nav__item story-nav__item--next"
|
||||
>
|
||||
<span class="story-nav__label">下一个故事</span>
|
||||
<span class="story-nav__title">{{ nextStory.title }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="story-sidebar">
|
||||
<!-- 咨询 CTA -->
|
||||
<div class="sidebar-card sidebar-card--cta">
|
||||
<h3 class="sidebar-card__title">想要一段类似的旅行?</h3>
|
||||
<p class="sidebar-card__desc">联系呼籁定制师,一对一规划专属家庭行程。</p>
|
||||
<CommonInternalLink to="/contact" text="免费咨询定制师" />
|
||||
</div>
|
||||
|
||||
<!-- 所有故事 -->
|
||||
<div class="sidebar-card">
|
||||
<h3 class="sidebar-card__title">更多家庭故事</h3>
|
||||
<ul class="sidebar-stories">
|
||||
<li
|
||||
v-for="s in allStories"
|
||||
:key="s.id"
|
||||
class="sidebar-story"
|
||||
:class="{ 'sidebar-story--active': s.id === story.id }"
|
||||
>
|
||||
<NuxtLink :to="`/stories/${s.id}`" class="sidebar-story__link">
|
||||
<span class="sidebar-story__family">{{ s.familyType }}</span>
|
||||
<span class="sidebar-story__title">{{ s.title }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 快速链接 -->
|
||||
<div class="sidebar-card">
|
||||
<h3 class="sidebar-card__title">相关内容</h3>
|
||||
<div class="sidebar-links">
|
||||
<CommonInternalLink to="/reviews" text="查看更多客户评价" />
|
||||
<CommonInternalLink to="/products" text="旅行产品详情" />
|
||||
<CommonInternalLink to="/faq" text="常见问答" />
|
||||
<CommonInternalLink to="/qualifications" text="资质与保障" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 404 -->
|
||||
<div v-else class="page-not-found">
|
||||
<div class="container">
|
||||
<h1>故事不存在</h1>
|
||||
<NuxtLink to="/stories">返回客户故事列表</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema, useCustomerStorySchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: storiesData } = await usePublicApi('stories')
|
||||
|
||||
const route = useRoute()
|
||||
const allStories = computed(() => storiesData.value?.stories || [])
|
||||
const story = computed(() => allStories.value.find(s => s.id === route.params.id))
|
||||
|
||||
if (!story.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: '故事不存在' })
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: story.value.seo.title,
|
||||
meta: [
|
||||
{ name: 'description', content: story.value.seo.description },
|
||||
{ name: 'keywords', content: story.value.seo.keywords },
|
||||
{ name: 'author', content: story.value.authorName },
|
||||
{ name: 'article:published_time', content: story.value.travelDate + '-01T00:00:00+08:00' },
|
||||
{ name: 'geo.region', content: 'CN-NM' },
|
||||
{ name: 'geo.placename', content: '呼伦贝尔' },
|
||||
{ property: 'og:title', content: story.value.seo.title },
|
||||
{ property: 'og:description', content: story.value.seo.description },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:url', content: `https://1814.love/stories/${story.value.id}` },
|
||||
{ property: 'og:site_name', content: '呼籁旅行' },
|
||||
{ property: 'og:locale', content: 'zh_CN' },
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: `https://1814.love/stories/${story.value.id}` },
|
||||
],
|
||||
})
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '客户故事', to: '/stories' },
|
||||
{ text: story.value.familyType },
|
||||
{ text: story.value.title },
|
||||
])
|
||||
|
||||
useCustomerStorySchema(story.value)
|
||||
|
||||
// 上下篇
|
||||
const currentIndex = computed(() => allStories.value.findIndex(s => s.id === story.value?.id))
|
||||
const prevStory = computed(() => currentIndex.value > 0 ? allStories.value[currentIndex.value - 1] : null)
|
||||
const nextStory = computed(() => currentIndex.value < allStories.value.length - 1 ? allStories.value[currentIndex.value + 1] : null)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.story-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-2xl;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr 300px;
|
||||
align-items: start;
|
||||
});
|
||||
}
|
||||
|
||||
.story-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// 元信息
|
||||
.story-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
margin-bottom: @space-lg;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
.story-meta__family {
|
||||
padding: 3px @space-sm;
|
||||
background: @color-primary;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
.story-meta__children,
|
||||
.story-meta__city,
|
||||
.story-meta__date {
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.story-meta__product {
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
font-size: @font-size-xs;
|
||||
padding: 2px @space-sm;
|
||||
background: @color-primary-bg;
|
||||
border-radius: @border-radius-full;
|
||||
}
|
||||
|
||||
// 配图占位
|
||||
.story-image-placeholder {
|
||||
margin-bottom: @space-xl;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 7;
|
||||
background: @color-primary-bg;
|
||||
border: 2px dashed @color-primary-lighter;
|
||||
|
||||
&__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: @space-xl;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
text-align: center;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 摘要
|
||||
.story-summary {
|
||||
padding: @space-lg;
|
||||
background: @color-primary-bg;
|
||||
border-left: 4px solid @color-primary;
|
||||
border-radius: 0 @border-radius-md @border-radius-md 0;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
}
|
||||
|
||||
// 故事章节
|
||||
.story-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xl;
|
||||
margin-bottom: @space-2xl;
|
||||
}
|
||||
|
||||
.story-section {
|
||||
&__heading {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-md;
|
||||
padding-bottom: @space-sm;
|
||||
border-bottom: 2px solid @color-primary-bg;
|
||||
}
|
||||
|
||||
&__content {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 印象瞬间
|
||||
.story-moments {
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-xl;
|
||||
margin-bottom: @space-2xl;
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-lg;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.moment-item {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
align-items: flex-start;
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签
|
||||
.story-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
margin-top: @space-2xl;
|
||||
padding-top: @space-lg;
|
||||
border-top: 1px solid @color-border;
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.story-tags__label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.story-tag {
|
||||
padding: 3px @space-sm;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-sm;
|
||||
font-size: @font-size-xs;
|
||||
}
|
||||
|
||||
// 前后导航
|
||||
.story-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: @space-md;
|
||||
padding-top: @space-lg;
|
||||
border-top: 1px solid @color-border;
|
||||
|
||||
&__item {
|
||||
padding: @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&--next {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
}
|
||||
|
||||
// 侧边栏
|
||||
.story-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-lg;
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-lg;
|
||||
|
||||
&--cta {
|
||||
background: @color-primary-bg;
|
||||
border-color: @color-primary-lighter;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0 0 @space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-stories {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-story {
|
||||
&--active .sidebar-story__link {
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: @space-sm;
|
||||
border-radius: @border-radius-sm;
|
||||
text-decoration: none;
|
||||
transition: background @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&__family {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-primary;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
// 404
|
||||
.page-not-found {
|
||||
text-align: center;
|
||||
padding: @space-3xl 0;
|
||||
|
||||
h1 {
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
a {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="page-stories">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="来自小红书的真实旅行分享"
|
||||
:breadcrumbs="[{ text: '客户故事' }]"
|
||||
background-image="/images/summer-camp/birch-group.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR:告诉 AI/搜索引擎这个页面回答了什么 -->
|
||||
<section class="page-tldr" aria-label="客户故事摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行客户故事:真实家庭旅行记录,包括四口之家的草原初体验、三代同游、带幼儿出行、女友结伴公路旅行等。非好评墙,是有具体细节的旅途记录,帮助准备出行的家庭建立真实预期。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 瀑布流卡片墙 -->
|
||||
<section class="section section-stories">
|
||||
<div class="container">
|
||||
<div v-if="readyStories.length > 0" class="stories-wall">
|
||||
<component
|
||||
:is="getCardComponent(story)"
|
||||
v-for="story in readyStories"
|
||||
:key="story.id"
|
||||
v-bind="getCardProps(story)"
|
||||
class="story-card reveal"
|
||||
>
|
||||
<div class="story-card__visual">
|
||||
<img
|
||||
v-if="story.coverImage"
|
||||
:src="story.coverImage"
|
||||
:alt="story.title"
|
||||
class="story-card__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="story-card__img-placeholder" />
|
||||
<!-- 小红书外链角标 -->
|
||||
<span v-if="story.sourceUrl" class="story-card__badge">
|
||||
<svg class="story-card__badge-icon" viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M7.5 2h9a1.5 1.5 0 011.5 1.5v17a1.5 1.5 0 01-2.3 1.27L12 19l-3.7 2.77A1.5 1.5 0 016 20.5v-17A1.5 1.5 0 017.5 2z"/>
|
||||
</svg>
|
||||
小红书
|
||||
</span>
|
||||
<!-- 原创故事标记 -->
|
||||
<span v-else-if="story.content" class="story-card__badge story-card__badge--original">
|
||||
原创故事
|
||||
</span>
|
||||
<!-- 家庭类型 -->
|
||||
<span class="story-card__family">{{ story.familyType }}</span>
|
||||
</div>
|
||||
<div class="story-card__body">
|
||||
<h2 class="story-card__title">{{ story.title }}</h2>
|
||||
<p class="story-card__desc">{{ story.subtitle || story.summary }}</p>
|
||||
<div class="story-card__meta">
|
||||
<span v-if="story.author" class="story-card__author">
|
||||
{{ story.author }}
|
||||
</span>
|
||||
<span class="story-card__date">{{ story.travelDate }}</span>
|
||||
</div>
|
||||
<div class="story-card__tags">
|
||||
<span v-for="tag in (story.tags || []).slice(0, 3)" :key="tag" class="story-tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<div v-else class="stories-coming-soon reveal">
|
||||
<p class="stories-coming-soon__text">更多真实旅行故事整理中,敬请期待。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内链 -->
|
||||
<CommonSectionCTA
|
||||
title="这些故事,可能也会是你的故事"
|
||||
description="联系定制师,开始规划您的呼伦贝尔家庭旅行"
|
||||
:links="[
|
||||
{ to: '/contact', text: '联系定制师规划行程' },
|
||||
{ to: '/reviews', text: '查看更多客户评价' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { resolveComponent } from 'vue'
|
||||
import { useOrganizationSchema, useBreadcrumbSchema, useStoriesListSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: storiesData } = await usePublicApi('stories')
|
||||
const { data: brand } = await usePublicApi('brand')
|
||||
const { data: contact } = await usePublicApi('contact')
|
||||
|
||||
const seo = useSEO('stories')
|
||||
useScrollReveal()
|
||||
useOrganizationSchema(brand.value, contact.value)
|
||||
useStoriesListSchema(storiesData.value?.stories)
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '客户故事' },
|
||||
])
|
||||
|
||||
const stories = computed(() => storiesData.value?.stories || [])
|
||||
|
||||
const readyStories = computed(() =>
|
||||
stories.value.filter(s => s.summary && !s.summary.includes('⚠️'))
|
||||
)
|
||||
|
||||
// 占位内容未替换时,阻止搜索引擎索引空页面
|
||||
if (readyStories.value.length === 0) {
|
||||
useHead({ meta: [{ key: 'robots', name: 'robots', content: 'noindex, nofollow' }] })
|
||||
}
|
||||
|
||||
// 原创故事用 NuxtLink,xhs 故事用 <a> 外链
|
||||
function getCardComponent(story) {
|
||||
return story.sourceUrl ? 'a' : resolveComponent('NuxtLink')
|
||||
}
|
||||
|
||||
function getCardProps(story) {
|
||||
if (story.sourceUrl) {
|
||||
return {
|
||||
href: story.sourceUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener nofollow',
|
||||
}
|
||||
}
|
||||
return { to: `/stories/${story.id}` }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.section-stories {
|
||||
background: @color-bg-gray;
|
||||
padding-bottom: @space-3xl;
|
||||
}
|
||||
|
||||
// 瀑布流网格
|
||||
.stories-wall {
|
||||
column-count: 2;
|
||||
column-gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
column-count: 3;
|
||||
});
|
||||
|
||||
.respond-xl({
|
||||
column-count: 4;
|
||||
column-gap: @space-lg;
|
||||
});
|
||||
}
|
||||
|
||||
// 卡片
|
||||
.story-card {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
break-inside: avoid;
|
||||
margin-bottom: @space-md;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all @transition-base;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.respond-xl({
|
||||
margin-bottom: @space-lg;
|
||||
});
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: @shadow-md;
|
||||
border-color: @color-primary-light;
|
||||
}
|
||||
|
||||
&__visual {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
// 不固定高度,让图片保持原始比例实现瀑布流效果
|
||||
}
|
||||
|
||||
&__img-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
// 角标(小红书/原创)
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: @space-sm;
|
||||
right: @space-sm;
|
||||
background: rgba(255, 46, 46, 0.9);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px @space-sm;
|
||||
border-radius: @border-radius-full;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-weight: @font-weight-medium;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
&--original {
|
||||
background: rgba(45, 106, 79, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&__badge-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 家庭类型
|
||||
&__family {
|
||||
position: absolute;
|
||||
bottom: @space-sm;
|
||||
left: @space-sm;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: @font-size-xs;
|
||||
padding: 2px @space-sm;
|
||||
border-radius: @border-radius-full;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: @space-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin: 0;
|
||||
line-height: @line-height-base;
|
||||
.text-clamp(2);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__author {
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-xs;
|
||||
margin-top: @space-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.story-tag {
|
||||
font-size: 11px;
|
||||
color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
padding: 1px @space-sm;
|
||||
border-radius: @border-radius-full;
|
||||
}
|
||||
|
||||
.stories-coming-soon {
|
||||
text-align: center;
|
||||
padding: @space-3xl 0;
|
||||
&__text {
|
||||
font-size: @font-size-lg;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<div class="page-summer-camp">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="呼伦贝尔草原亲子夏令营·6天5晚·每期8组家庭"
|
||||
:breadcrumbs="[{ text: '小蒙马夏令营' }]"
|
||||
background-image="/images/summer-camp/river-banner.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR:告诉 AI/搜索引擎这个页面回答了什么 -->
|
||||
<section class="page-tldr" aria-label="夏令营摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>小蒙马夏令营:呼伦贝尔草原亲子夏令营,6天5晚,每期限8组家庭,6-12岁儿童适合。亲子同行,非托管模式,草原骑马、篝火晚会、营地课程、牧民拜访等体验。7月旺季需提前2-3个月报名。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心定位 -->
|
||||
<section v-if="camp" class="section">
|
||||
<div class="container">
|
||||
<div class="camp-hero">
|
||||
<div class="camp-mascot">
|
||||
<img
|
||||
:src="images.mascot.xiaomengma"
|
||||
alt="小蒙马 - 小蒙马夏令营吉祥物,一只可爱的蒙古小马形象"
|
||||
width="200"
|
||||
height="200"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="camp-intro">
|
||||
<h2 class="camp-slogan">孩子在探索世界,你也在找回自己</h2>
|
||||
<p class="camp-positioning">{{ camp.positioning }}</p>
|
||||
<div class="camp-meta">
|
||||
<span class="camp-meta-item">{{ camp.days }}天{{ camp.nights }}晚</span>
|
||||
<span class="camp-meta-item">每期8组家庭</span>
|
||||
<span class="camp-meta-item">亲子同行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 实拍照片 + 期数数据 -->
|
||||
<section v-if="camp && images" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<div class="camp-gallery">
|
||||
<div class="camp-photo">
|
||||
<img
|
||||
:src="images.summerCamp.river"
|
||||
alt="小蒙马亲子夏令营在莫日格勒河旁举旗合影,背景是蜿蜒的草原河流"
|
||||
width="1600"
|
||||
height="1067"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="camp-photo-tag">莫日格勒河</span>
|
||||
</div>
|
||||
<div class="camp-photo">
|
||||
<img
|
||||
:src="images.summerCamp.grassland"
|
||||
alt="小蒙马夏令营亲子家庭在呼伦贝尔草原上合影,手持小蒙马标志牌"
|
||||
width="1600"
|
||||
height="1067"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="camp-photo-tag">草原合影</span>
|
||||
</div>
|
||||
<div class="camp-photo">
|
||||
<img
|
||||
:src="images.summerCamp.birch"
|
||||
alt="小蒙马夏令营亲子家庭在白桦林合影,家长和孩子们一起开心比耶"
|
||||
width="1600"
|
||||
height="1067"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="camp-photo-tag">白桦林研学</span>
|
||||
</div>
|
||||
<div class="camp-photo">
|
||||
<img
|
||||
:src="images.summerCamp.graduation"
|
||||
alt="小蒙马夏令营结营仪式,家长和孩子们手持结营证书在营地合影"
|
||||
width="1600"
|
||||
height="1067"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="camp-photo-tag">结营仪式</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="camp-stats">
|
||||
<div class="camp-stat-item">
|
||||
<span class="camp-stat-value">{{ camp.sessions['2025'] }}</span>
|
||||
<span class="camp-stat-label">2025年成功举办期数</span>
|
||||
</div>
|
||||
<div class="camp-stat-item">
|
||||
<span class="camp-stat-value">{{ camp.sessions['2026plan'] }}</span>
|
||||
<span class="camp-stat-label">2026年计划开营期数</span>
|
||||
</div>
|
||||
<div class="camp-stat-item">
|
||||
<span class="camp-stat-value">8</span>
|
||||
<span class="camp-stat-label">每期限定家庭数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 中间 CTA:看完照片后的转化入口 -->
|
||||
<CommonSectionCTA
|
||||
title="想了解营期安排和费用?"
|
||||
description="添加微信客服,获取最新营期时间和报名方式"
|
||||
:links="[{ to: '/contact', text: '咨询小蒙马夏令营' }]"
|
||||
/>
|
||||
|
||||
<!-- 四大原则 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="行程设计四大原则" subtitle="每一处安排都经过精心考量" />
|
||||
<div class="principles-grid">
|
||||
<div v-for="(p, i) in camp.principles" :key="i" class="principle-card">
|
||||
<div class="principle-index">{{ String(i + 1).padStart(2, '0') }}</div>
|
||||
<h3 class="principle-name">{{ p }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 行程概览 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="6天行程概览" subtitle="不赶路、不折腾,每一天都有记忆点" />
|
||||
<div class="itinerary-list">
|
||||
<div v-for="(day, i) in camp.itinerary" :key="i" class="itinerary-item">
|
||||
<div class="itinerary-day">D{{ day.day || i + 1 }}</div>
|
||||
<p class="itinerary-text">{{ day.title }}{{ day.summary ? ' ' + day.summary : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心体验 -->
|
||||
<section v-if="camp" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="行程核心体验" subtitle="草原写生·非遗研学·民俗体验,每一天都有收获" />
|
||||
<div class="activities-grid">
|
||||
<div v-for="(activity, i) in camp.coreActivities" :key="i" class="activity-card">
|
||||
<div class="activity-index">{{ String(i + 1).padStart(2, '0') }}</div>
|
||||
<p class="activity-text">{{ activity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 与V9的区别 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="小蒙马 vs 额吉的故乡" subtitle="两种产品,两种精彩" />
|
||||
<div class="compare-cards">
|
||||
<div class="compare-card compare-card--camp">
|
||||
<h3>小蒙马夏令营</h3>
|
||||
<ul>
|
||||
<li>亲子同行,家长和孩子一起出发</li>
|
||||
<li>每期8组家庭结伴同行,孩子有玩伴</li>
|
||||
<li>草原写生+非遗研学+民俗体验</li>
|
||||
<li>设有「爸妈托管」时段,家长也能放松</li>
|
||||
<li>孩子有伙伴一起学、一起玩</li>
|
||||
<li>6天5晚,节奏舒缓不赶路</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-card compare-card--v9">
|
||||
<h3>额吉的故乡V9</h3>
|
||||
<ul>
|
||||
<li>全家一起出行,行程灵活自由</li>
|
||||
<li>一家一单一车,专属私家定制</li>
|
||||
<li>专属领队一对一服务</li>
|
||||
<li>行程可根据需求随时调整</li>
|
||||
<li>享受亲密家庭时光</li>
|
||||
<li>4-7天多版本可选</li>
|
||||
</ul>
|
||||
<NuxtLink to="/products" class="compare-link">查看V9系列 →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<p class="compare-detail">{{ camp.differenceFromV9 }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 家长常见问题(可折叠) -->
|
||||
<section class="section section--gray">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="家长常见问题" />
|
||||
<div class="camp-faq">
|
||||
<FaqItem
|
||||
v-for="(item, i) in campFAQ"
|
||||
:key="item.question || item.q"
|
||||
:question="item.question || item.q"
|
||||
:answer="item.answer || item.a"
|
||||
:initial-open="i === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部 CTA:双入口 -->
|
||||
<CommonSectionCTA
|
||||
title="想了解家庭一起出行的产品?"
|
||||
description="额吉的故乡V9系列,一家一单一车,全家共享草原时光"
|
||||
:links="[
|
||||
{ to: '/contact', text: '咨询小蒙马夏令营' },
|
||||
{ to: '/products', text: '查看额吉的故乡V9系列' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema, useSummerCampSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: camp } = await usePublicApi('summer-camp')
|
||||
const { data: images } = await usePublicApi('images')
|
||||
|
||||
const seo = useSEO('summer-camp')
|
||||
useScrollReveal()
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '小蒙马夏令营' },
|
||||
])
|
||||
useSummerCampSchema(camp.value)
|
||||
|
||||
// 家长关心的问题(从数据文件读取)
|
||||
const campFAQ = computed(() => camp.value?.faq || [])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.camp-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: @space-xl;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.respond-md({
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
gap: @space-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
.camp-mascot {
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 160px;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
}
|
||||
|
||||
.camp-intro {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.camp-slogan {
|
||||
font-size: @font-size-2xl;
|
||||
color: @color-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.camp-positioning {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
.camp-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.camp-meta-item {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
padding: @space-xs @space-md;
|
||||
border-radius: @border-radius-full;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// 实拍照片
|
||||
.camp-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.camp-photo {
|
||||
position: relative;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: transform @transition-slow;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
.camp-photo-tag {
|
||||
position: absolute;
|
||||
bottom: @space-sm;
|
||||
left: @space-sm;
|
||||
font-size: @font-size-xs;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: @space-xs @space-sm;
|
||||
border-radius: @border-radius-full;
|
||||
}
|
||||
|
||||
.camp-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: @space-xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.camp-stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.camp-stat-value {
|
||||
display: block;
|
||||
font-size: @font-size-3xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.camp-stat-label {
|
||||
display: block;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
margin-top: @space-xs;
|
||||
}
|
||||
|
||||
// 四大原则
|
||||
.principles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.principle-card {
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
text-align: center;
|
||||
transition: border-color @transition-fast, box-shadow @transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary-lighter;
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.principle-index {
|
||||
font-size: 28px;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary-lighter;
|
||||
line-height: 1;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.principle-name {
|
||||
font-size: @font-size-md;
|
||||
color: @color-text-primary;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// 行程概览
|
||||
.itinerary-list {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.itinerary-item {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
align-items: flex-start;
|
||||
padding: @space-md @space-lg;
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-md;
|
||||
border-left: 3px solid @color-primary;
|
||||
transition: background @transition-fast, border-color @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.itinerary-day {
|
||||
flex-shrink: 0;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.itinerary-text {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 核心活动
|
||||
.activities-grid {
|
||||
.grid-responsive();
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
padding: @space-xl;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
transition: border-color @transition-fast, box-shadow @transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary-lighter;
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-index {
|
||||
font-size: @font-size-3xl;
|
||||
font-weight: @font-weight-bold;
|
||||
margin-bottom: @space-sm;
|
||||
line-height: 1;
|
||||
color: @color-primary-lighter;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 对比卡片
|
||||
.compare-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
max-width: 800px;
|
||||
margin: 0 auto @space-xl;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: 1fr 1fr;
|
||||
});
|
||||
}
|
||||
|
||||
.compare-card {
|
||||
padding: @space-xl;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
|
||||
h3 {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
padding-left: @space-md;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: @color-primary;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compare-card--camp {
|
||||
background: @color-primary-bg;
|
||||
border-color: fadeout(@color-primary, 80%);
|
||||
|
||||
h3 {
|
||||
color: @color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.compare-card--v9 {
|
||||
background: @color-bg-white;
|
||||
}
|
||||
|
||||
.compare-link {
|
||||
display: inline-block;
|
||||
margin-top: @space-md;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.compare-detail {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
padding: @space-lg;
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-md;
|
||||
}
|
||||
|
||||
// 家长FAQ
|
||||
.camp-faq {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,647 @@
|
||||
<template>
|
||||
<div class="page-winter-camp">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1"
|
||||
subtitle="呼伦贝尔雪原亲子冬令营·7天6晚·每期8组家庭"
|
||||
:breadcrumbs="[{ text: '小蒙马冬季营' }]"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="冬令营摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>小蒙马冬令营:7天6晚呼伦贝尔雪原亲子冬令营,每期仅限8组家庭。冰雪越野、雪地骑马、冬捕体验、蒙古包住宿,零下30度的童话世界。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 风景大图 -->
|
||||
<div class="scenic-banner">
|
||||
<img src="/images/seasons/winter-yurt.jpg" alt="冬季呼伦贝尔雪原蒙古包" loading="eager" />
|
||||
</div>
|
||||
|
||||
<!-- 核心定位 -->
|
||||
<section v-if="camp" class="section">
|
||||
<div class="container">
|
||||
<div class="camp-hero">
|
||||
<div class="camp-mascot">
|
||||
<img
|
||||
:src="images.mascot.xiaomengma"
|
||||
alt="小蒙马 - 冬季亲子营吉祥物"
|
||||
width="200"
|
||||
height="200"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="camp-intro">
|
||||
<h2 class="camp-slogan">一片干净的雪原,一段只属于你们的记忆</h2>
|
||||
<p class="camp-positioning">{{ camp.positioning }}</p>
|
||||
<div class="camp-meta">
|
||||
<span class="camp-meta-item">{{ camp.days }}天{{ camp.nights }}晚</span>
|
||||
<span class="camp-meta-item">每期{{ camp.maxFamilies }}组家庭</span>
|
||||
<span class="camp-meta-item">今年仅{{ camp.totalSessions }}期</span>
|
||||
<span class="camp-meta-item">{{ camp.ageRange }}亲子</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 为什么选呼伦贝尔 -->
|
||||
<section v-if="camp" class="section section--ice reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="为什么是呼伦贝尔?" subtitle="不是更冷,而是更纯粹" />
|
||||
<div class="why-block">
|
||||
<p>{{ camp.whyHulunbuir }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 亲子营优势 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="亲子营的优势" subtitle="和独立小包团相比" />
|
||||
<div class="advantages-grid">
|
||||
<div v-for="(adv, i) in camp.campAdvantages" :key="i" class="advantage-card">
|
||||
<span class="advantage-check">✓</span>
|
||||
<p>{{ adv }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 咨询 CTA -->
|
||||
<CommonSectionCTA
|
||||
title="想了解营期安排和费用?"
|
||||
description="添加微信客服,获取最新营期时间和报名方式"
|
||||
:links="[{ to: '/contact', text: '咨询小蒙马冬季营' }]"
|
||||
/>
|
||||
|
||||
<!-- 住宿保障 -->
|
||||
<section v-if="camp" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="住宿安排" subtitle="住得舒服,玩得才能惬意" />
|
||||
<div class="hotels-grid">
|
||||
<div v-for="(hotel, i) in camp.hotels" :key="i" class="hotel-card">
|
||||
<div class="hotel-star">{{ hotel.star }}</div>
|
||||
<h3 class="hotel-name">{{ hotel.name }}</h3>
|
||||
<span class="hotel-nights">{{ hotel.nights }}</span>
|
||||
<p class="hotel-desc">{{ hotel.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7天行程 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="7天行程概览" subtitle="不赶路、不折腾,每一天都有记忆点" />
|
||||
<div class="itinerary-list">
|
||||
<details v-for="day in camp.itinerary" :key="day.day" class="itinerary-item" :open="day.day <= 2">
|
||||
<summary class="itinerary-header">
|
||||
<span class="itinerary-day">D{{ day.day }}</span>
|
||||
<span class="itinerary-title">{{ day.title }}</span>
|
||||
<span v-if="day.hotel" class="itinerary-hotel">🏨 {{ day.hotel }}</span>
|
||||
</summary>
|
||||
<div class="itinerary-body">
|
||||
<p class="itinerary-summary">{{ day.summary }}</p>
|
||||
<ul class="itinerary-highlights">
|
||||
<li v-for="(h, j) in day.highlights" :key="j">{{ h }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 冬季风景 -->
|
||||
<div class="scenic-duo reveal">
|
||||
<img src="/images/seasons/winter-yurt.jpg" alt="冬季雪原蒙古包日落" loading="lazy" />
|
||||
<img src="/images/seasons/winter-frost.jpg" alt="大兴安岭雾凇森林" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- 摄影师 -->
|
||||
<section v-if="camp" class="section section--ice reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="随行摄影师" subtitle="不错过每一个自然的瞬间" />
|
||||
<div class="photographer-block">
|
||||
<h3>摄影师:{{ camp.photographer.name }}</h3>
|
||||
<p>{{ camp.photographer.description }}</p>
|
||||
<p class="photographer-special">📍 {{ camp.photographer.specialDay2 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 穿衣指南 -->
|
||||
<section v-if="camp" class="section reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="冬季穿衣指南" subtitle="穿对了,冷也是一种享受" />
|
||||
<div class="clothing-grid">
|
||||
<div class="clothing-card">
|
||||
<h4>👕 上身</h4>
|
||||
<p>{{ camp.winterClothing.upper }}</p>
|
||||
</div>
|
||||
<div class="clothing-card">
|
||||
<h4>👖 下身</h4>
|
||||
<p>{{ camp.winterClothing.lower }}</p>
|
||||
</div>
|
||||
<div class="clothing-card">
|
||||
<h4>👟 鞋子</h4>
|
||||
<p>{{ camp.winterClothing.shoes }}</p>
|
||||
</div>
|
||||
<div class="clothing-card">
|
||||
<h4>🧣 配件</h4>
|
||||
<p>{{ camp.winterClothing.accessories }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 随行服务 -->
|
||||
<section v-if="camp" class="section section--warm reveal">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="随行服务配置" subtitle="比夏季更谨慎、更细致" />
|
||||
<div class="service-grid">
|
||||
<div v-for="(s, i) in camp.serviceConfig" :key="i" class="service-item">
|
||||
<span class="service-icon">👤</span>
|
||||
<span>{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 家长FAQ -->
|
||||
<section v-if="camp?.faq" class="section section--gray">
|
||||
<div class="container">
|
||||
<CommonSectionTitle title="家长常见问题" />
|
||||
<div class="camp-faq">
|
||||
<FaqItem
|
||||
v-for="(item, i) in camp.faq"
|
||||
:key="item.question"
|
||||
:question="item.question"
|
||||
:answer="item.answer"
|
||||
:initial-open="i === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 结语 -->
|
||||
<section v-if="camp" class="section">
|
||||
<div class="container">
|
||||
<div class="closing-note">
|
||||
<p>{{ camp.closingNote }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部 CTA -->
|
||||
<CommonSectionCTA
|
||||
title="给孩子一次真正的冬天"
|
||||
description="小蒙马冬季亲子营,今年仅开3期,全力以赴"
|
||||
:links="[
|
||||
{ to: '/contact', text: '咨询冬季营' },
|
||||
{ to: '/summer-camp', text: '了解夏季小蒙马' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: camp } = await usePublicApi('winter-camp')
|
||||
const { data: images } = await usePublicApi('images')
|
||||
|
||||
const seo = useSEO('winter-camp', {
|
||||
title: '小蒙马冬季亲子营 - 呼伦贝尔雪原冬令营·7天6晚·每期8组家庭 - 呼籁旅行',
|
||||
description: '呼籁旅行小蒙马冬季亲子营,7天6晚呼伦贝尔+阿尔山雪原冬令营。莫日格勒大雪原、冬季那达慕、狼园探秘、雪地骑马、阿尔山滑雪、不冻河漂流、私汤温泉。每期8组家庭,5-12岁亲子同行。',
|
||||
h1: '小蒙马冬季亲子营',
|
||||
})
|
||||
useScrollReveal()
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '小蒙马冬季营' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// Hero 区
|
||||
.camp-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: @space-xl;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.respond-md({
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
gap: @space-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
.camp-mascot {
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 160px;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
}
|
||||
|
||||
.camp-intro {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.camp-slogan {
|
||||
font-size: @font-size-2xl;
|
||||
color: #3B82F6;
|
||||
font-weight: @font-weight-bold;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.camp-positioning {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
.camp-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.camp-meta-item {
|
||||
font-size: @font-size-sm;
|
||||
color: #3B82F6;
|
||||
background: #EFF6FF;
|
||||
padding: @space-xs @space-md;
|
||||
border-radius: @border-radius-full;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// 为什么呼伦贝尔
|
||||
.section--ice {
|
||||
background: #F0F9FF;
|
||||
}
|
||||
|
||||
.why-block {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
padding: @space-xl;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
border-left: 4px solid #3B82F6;
|
||||
}
|
||||
|
||||
// 亲子营优势
|
||||
.advantages-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-md;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.advantage-card {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
align-items: flex-start;
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
transition: box-shadow @transition-fast;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.advantage-check {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #3B82F6;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
|
||||
// 酒店
|
||||
.hotels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-lg;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.hotel-card {
|
||||
padding: @space-xl;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
text-align: center;
|
||||
transition: box-shadow @transition-base, transform @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.hotel-star {
|
||||
display: inline-block;
|
||||
padding: @space-xs @space-md;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-bold;
|
||||
color: #B45309;
|
||||
background: #FEF3C7;
|
||||
border-radius: @border-radius-full;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
.hotel-name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-xs;
|
||||
}
|
||||
|
||||
.hotel-nights {
|
||||
display: block;
|
||||
font-size: @font-size-sm;
|
||||
color: #3B82F6;
|
||||
font-weight: @font-weight-medium;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.hotel-desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// 行程
|
||||
.itinerary-list {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.itinerary-item {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
transition: box-shadow @transition-fast;
|
||||
|
||||
&[open] {
|
||||
box-shadow: @shadow-md;
|
||||
border-color: #93C5FD;
|
||||
}
|
||||
}
|
||||
|
||||
.itinerary-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
padding: @space-md @space-lg;
|
||||
cursor: pointer;
|
||||
transition: background @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: #F0F9FF;
|
||||
}
|
||||
}
|
||||
|
||||
.itinerary-day {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: @font-weight-bold;
|
||||
color: #3B82F6;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.itinerary-title {
|
||||
flex: 1;
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
.itinerary-hotel {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.itinerary-body {
|
||||
padding: 0 @space-lg @space-lg;
|
||||
padding-left: calc(@space-lg + 36px + @space-sm);
|
||||
}
|
||||
|
||||
.itinerary-summary {
|
||||
font-size: @font-size-sm;
|
||||
color: #3B82F6;
|
||||
font-weight: @font-weight-medium;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.itinerary-highlights {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
|
||||
li {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
padding-left: @space-md;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '❄';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #3B82F6;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 摄影师
|
||||
.photographer-block {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
font-size: @font-size-xl;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.photographer-special {
|
||||
padding: @space-md @space-lg;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-md;
|
||||
border-left: 3px solid #3B82F6;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// 穿衣指南
|
||||
.clothing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
});
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
});
|
||||
}
|
||||
|
||||
.clothing-card {
|
||||
padding: @space-lg;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
|
||||
h4 {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin-bottom: @space-sm;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
}
|
||||
}
|
||||
|
||||
// 服务配置
|
||||
.service-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: @space-md;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.service-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
padding: @space-sm @space-lg;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
// 结语
|
||||
.closing-note {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
font-size: @font-size-lg;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-loose;
|
||||
font-style: italic;
|
||||
padding: @space-xl;
|
||||
border-top: 1px solid @color-border;
|
||||
border-bottom: 1px solid @color-border;
|
||||
}
|
||||
|
||||
// FAQ
|
||||
.camp-faq {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
// 风景图
|
||||
.scenic-banner {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
max-height: 480px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.scenic-duo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
max-height: 360px;
|
||||
overflow: hidden;
|
||||
|
||||
.respond-md({
|
||||
grid-template-columns: 1fr 1fr;
|
||||
});
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
max-height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,612 @@
|
||||
<template>
|
||||
<div v-if="note" class="page-xhs-detail">
|
||||
<LayoutPageHero
|
||||
:title="note.title"
|
||||
:subtitle="`来自小红书用户 @${note.author} 的真实旅行分享`"
|
||||
:breadcrumbs="[{ text: '小红书游记', to: '/xiaohongshu' }, { text: note.title }]"
|
||||
compact
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="detail-layout">
|
||||
<!-- 主内容 -->
|
||||
<main class="detail-main">
|
||||
<!-- 封面图 -->
|
||||
<div class="detail-cover" :class="{ 'detail-cover--wide': note.ratio === 'horizontal' }">
|
||||
<img :src="note.cover" :alt="note.title" />
|
||||
</div>
|
||||
|
||||
<!-- 笔记正文预览 -->
|
||||
<article class="detail-content">
|
||||
<div class="detail-author-bar">
|
||||
<div class="detail-author-bar__left">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" opacity="0.5"><circle cx="12" cy="8" r="4"/><path d="M12 14c-6 0-8 3-8 3v1h16v-1s-2-3-8-3z"/></svg>
|
||||
<span class="detail-author-bar__name">{{ note.author }}</span>
|
||||
</div>
|
||||
<span class="detail-author-bar__likes">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#ff2442"/></svg>
|
||||
{{ formatLikes(note.likes) }} 赞
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 class="detail-content__title">{{ note.title }}</h2>
|
||||
|
||||
<div class="detail-content__body">
|
||||
<p>{{ note.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="detail-tags">
|
||||
<span v-for="tag in note.tags" :key="tag" class="detail-tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 查看原文 CTA -->
|
||||
<div class="detail-xhs-cta">
|
||||
<div class="detail-xhs-cta__icon">
|
||||
<svg viewBox="0 0 24 24" width="32" height="32"><circle cx="12" cy="12" r="11" fill="#ff2442" /><text x="12" y="16" text-anchor="middle" fill="white" font-size="10" font-weight="bold">书</text></svg>
|
||||
</div>
|
||||
<div class="detail-xhs-cta__text">
|
||||
<p class="detail-xhs-cta__hint">以上为笔记摘要,查看完整图文内容请前往小红书</p>
|
||||
<a
|
||||
:href="note.url"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
class="detail-xhs-cta__btn"
|
||||
>
|
||||
在小红书查看原文
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 前后导航 -->
|
||||
<div class="detail-nav">
|
||||
<NuxtLink
|
||||
v-if="prevNote"
|
||||
:to="`/xiaohongshu/${prevNote.id}`"
|
||||
class="detail-nav__item detail-nav__item--prev"
|
||||
>
|
||||
<span class="detail-nav__label">上一篇</span>
|
||||
<span class="detail-nav__title">{{ prevNote.title }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else />
|
||||
<NuxtLink
|
||||
v-if="nextNote"
|
||||
:to="`/xiaohongshu/${nextNote.id}`"
|
||||
class="detail-nav__item detail-nav__item--next"
|
||||
>
|
||||
<span class="detail-nav__label">下一篇</span>
|
||||
<span class="detail-nav__title">{{ nextNote.title }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="detail-sidebar">
|
||||
<!-- 小红书账号卡 -->
|
||||
<div class="sidebar-card sidebar-card--xhs">
|
||||
<div class="sidebar-card__account">
|
||||
<img src="/images/xiaohongshu/profile.jpg" alt="呼籁旅行" class="sidebar-card__avatar" />
|
||||
<div>
|
||||
<h3 class="sidebar-card__name">{{ wallData.account.name }}</h3>
|
||||
<span class="sidebar-card__badge">蓝V认证 · 旅行社</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-card__stats">
|
||||
<span>粉丝 <strong>{{ wallData.account.followers }}</strong></span>
|
||||
<span>获赞 <strong>{{ wallData.account.likes }}</strong></span>
|
||||
</div>
|
||||
<a
|
||||
:href="wallData.account.url"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
class="sidebar-card__follow"
|
||||
>
|
||||
关注小红书
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 咨询 CTA -->
|
||||
<div class="sidebar-card sidebar-card--cta">
|
||||
<h3 class="sidebar-card__title">想要同款旅行体验?</h3>
|
||||
<p class="sidebar-card__desc">联系呼籁定制师,一对一规划专属行程</p>
|
||||
<CommonInternalLink to="/contact" text="免费咨询定制师" />
|
||||
</div>
|
||||
|
||||
<!-- 更多笔记 -->
|
||||
<div class="sidebar-card">
|
||||
<h3 class="sidebar-card__title">更多客户笔记</h3>
|
||||
<ul class="sidebar-notes">
|
||||
<li
|
||||
v-for="n in relatedNotes"
|
||||
:key="n.id"
|
||||
class="sidebar-note"
|
||||
:class="{ 'sidebar-note--active': n.id === note.id }"
|
||||
>
|
||||
<NuxtLink :to="`/xiaohongshu/${n.id}`" class="sidebar-note__link">
|
||||
<img :src="n.cover" :alt="n.title" class="sidebar-note__thumb" />
|
||||
<div class="sidebar-note__info">
|
||||
<span class="sidebar-note__title">{{ n.title }}</span>
|
||||
<span class="sidebar-note__author">@{{ n.author }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink to="/xiaohongshu" class="sidebar-card__more">查看全部笔记 →</NuxtLink>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<CommonSectionCTA
|
||||
title="想拥有同款草原体验?"
|
||||
description="联系定制师,开始规划您的呼伦贝尔之旅"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看旅行产品' },
|
||||
{ to: '/contact', text: '联系定制师' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 404 -->
|
||||
<div v-else class="page-not-found">
|
||||
<div class="container">
|
||||
<h1>笔记不存在</h1>
|
||||
<NuxtLink to="/xiaohongshu">返回小红书游记</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: wallData } = await usePublicApi('xiaohongshu-wall')
|
||||
|
||||
const route = useRoute()
|
||||
const allNotes = computed(() => wallData.value?.notes || [])
|
||||
const note = computed(() => allNotes.value.find(n => n.id === route.params.id))
|
||||
|
||||
if (!note.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: '笔记不存在' })
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: `${note.value.title} - 客户旅行笔记 | 呼籁旅行`,
|
||||
meta: [
|
||||
{ name: 'description', content: note.value.summary || `来自小红书用户 @${note.value.author} 的呼伦贝尔旅行笔记:${note.value.title}` },
|
||||
{ name: 'keywords', content: `呼伦贝尔,${note.value.tags.join(',')},呼籁旅行,小红书,旅行笔记,${note.value.author}` },
|
||||
{ name: 'author', content: note.value.author },
|
||||
{ property: 'og:title', content: note.value.title },
|
||||
{ property: 'og:description', content: note.value.summary },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:image', content: note.value.cover },
|
||||
{ property: 'og:url', content: `https://1814.love/xiaohongshu/${note.value.id}` },
|
||||
{ property: 'og:site_name', content: '呼籁旅行' },
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: `https://1814.love/xiaohongshu/${note.value.id}` },
|
||||
],
|
||||
})
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '小红书游记', to: '/xiaohongshu' },
|
||||
{ text: note.value.title },
|
||||
])
|
||||
|
||||
// 上下篇
|
||||
const currentIndex = computed(() => allNotes.value.findIndex(n => n.id === note.value?.id))
|
||||
const prevNote = computed(() => currentIndex.value > 0 ? allNotes.value[currentIndex.value - 1] : null)
|
||||
const nextNote = computed(() => currentIndex.value < allNotes.value.length - 1 ? allNotes.value[currentIndex.value + 1] : null)
|
||||
|
||||
// 相关笔记:同标签优先,取6条
|
||||
const relatedNotes = computed(() => {
|
||||
if (!note.value) return []
|
||||
const sameTag = allNotes.value.filter(n => n.id !== note.value.id && n.tags.some(t => note.value.tags.includes(t)))
|
||||
const others = allNotes.value.filter(n => n.id !== note.value.id && !sameTag.includes(n))
|
||||
return [...sameTag, ...others].slice(0, 6)
|
||||
})
|
||||
|
||||
function formatLikes(num) {
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + 'w'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
|
||||
return num
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-2xl;
|
||||
|
||||
.respond-lg({
|
||||
grid-template-columns: 1fr 320px;
|
||||
align-items: start;
|
||||
});
|
||||
}
|
||||
|
||||
.detail-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// ===== 封面 =====
|
||||
.detail-cover {
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
margin-bottom: @space-xl;
|
||||
aspect-ratio: 3 / 4;
|
||||
max-height: 560px;
|
||||
background: @gradient-cool;
|
||||
|
||||
&--wide {
|
||||
aspect-ratio: 4 / 3;
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 正文 =====
|
||||
.detail-content {
|
||||
margin-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.detail-author-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: @space-lg;
|
||||
padding-bottom: @space-md;
|
||||
border-bottom: 1px solid @color-divider;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
&__likes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
|
||||
svg { flex-shrink: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content__title {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-lg;
|
||||
line-height: @line-height-tight;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-3xl;
|
||||
});
|
||||
}
|
||||
|
||||
.detail-content__body {
|
||||
p {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
margin: 0 0 @space-md;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-md;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
margin-top: @space-lg;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
padding: 4px 14px;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// ===== 小红书原文 CTA =====
|
||||
.detail-xhs-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-lg;
|
||||
padding: @space-xl;
|
||||
background: linear-gradient(135deg, #fff5f7 0%, #ffe8ec 100%);
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid rgba(255, 36, 66, 0.1);
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
padding: 10px @space-xl;
|
||||
background: #ff2442;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-full;
|
||||
text-decoration: none;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
transition: all @transition-base;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
transition: transform @transition-fast;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #e6203b;
|
||||
box-shadow: 0 4px 16px rgba(255, 36, 66, 0.3);
|
||||
|
||||
svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 前后导航 =====
|
||||
.detail-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: @space-md;
|
||||
padding-top: @space-lg;
|
||||
border-top: 1px solid @color-border;
|
||||
|
||||
&__item {
|
||||
padding: @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-primary;
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&--next {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 侧边栏 =====
|
||||
.detail-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-lg;
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-lg;
|
||||
|
||||
&--xhs {
|
||||
border-color: rgba(255, 36, 66, 0.15);
|
||||
background: linear-gradient(180deg, #fff5f7 0%, #fff 40%);
|
||||
}
|
||||
|
||||
&--cta {
|
||||
background: @color-primary-bg;
|
||||
border-color: @color-primary-lighter;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-base;
|
||||
margin: 0 0 @space-md;
|
||||
}
|
||||
|
||||
&__more {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: @space-md;
|
||||
padding-top: @space-md;
|
||||
border-top: 1px solid @color-divider;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
text-decoration: none;
|
||||
font-weight: @font-weight-medium;
|
||||
|
||||
&:hover {
|
||||
color: @color-primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-card__account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-md;
|
||||
}
|
||||
|
||||
.sidebar-card__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(255, 36, 66, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sidebar-card__name {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.sidebar-card__badge {
|
||||
font-size: 11px;
|
||||
color: #ff2442;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
.sidebar-card__stats {
|
||||
display: flex;
|
||||
gap: @space-lg;
|
||||
margin-bottom: @space-md;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
|
||||
strong {
|
||||
color: @color-text-primary;
|
||||
font-weight: @font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-card__follow {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: @space-sm;
|
||||
background: #ff2442;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-full;
|
||||
text-decoration: none;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
transition: background @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: #e6203b;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-notes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.sidebar-note {
|
||||
&--active .sidebar-note__link {
|
||||
background: @color-primary-bg;
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
gap: @space-sm;
|
||||
padding: @space-sm;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
transition: background @transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: @color-bg-gray;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: @border-radius-sm;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-primary;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
|
||||
&__author {
|
||||
font-size: 11px;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// 404
|
||||
.page-not-found {
|
||||
text-align: center;
|
||||
padding: @space-3xl 0;
|
||||
|
||||
h1 { margin-bottom: @space-lg; }
|
||||
a { color: @color-primary; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,653 @@
|
||||
<template>
|
||||
<div class="page-xiaohongshu">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '小红书上的呼籁'"
|
||||
subtitle="来自真实客户的旅行笔记,点击查看原文"
|
||||
:breadcrumbs="[{ text: '小红书游记' }]"
|
||||
background-image="/images/seasons/summer-river.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="小红书摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行小红书官方账号精选笔记:来自真实客户的旅行分享和体验记录,点击可查看原文。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 账号名片 -->
|
||||
<section v-if="wallData" class="section section--account">
|
||||
<div class="container">
|
||||
<div class="account-card">
|
||||
<div class="account-card__inner">
|
||||
<div class="account-card__left">
|
||||
<div class="account-avatar">
|
||||
<img src="/images/xiaohongshu/profile.jpg" alt="呼籁旅行小红书头像" />
|
||||
<span class="account-avatar__badge">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="account-meta">
|
||||
<div class="account-meta__name">
|
||||
<h2>{{ wallData.account.name }}</h2>
|
||||
<span class="account-meta__verified">蓝V认证 · 旅行社</span>
|
||||
</div>
|
||||
<div class="account-meta__stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-item__value">{{ wallData.account.followers }}</span>
|
||||
<span class="stat-item__label">粉丝</span>
|
||||
</div>
|
||||
<span class="stat-divider"></span>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item__value">{{ wallData.account.likes }}</span>
|
||||
<span class="stat-item__label">获赞与收藏</span>
|
||||
</div>
|
||||
<span class="stat-divider"></span>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item__value">{{ wallData.notes.length }}</span>
|
||||
<span class="stat-item__label">精选笔记</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
:href="wallData.account.url"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
class="follow-btn"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><circle cx="12" cy="12" r="11" fill="#fff" /><text x="12" y="16" text-anchor="middle" fill="#ff2442" font-size="10" font-weight="bold">书</text></svg>
|
||||
关注我们
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 标签筛选 -->
|
||||
<section class="section section--tags">
|
||||
<div class="container">
|
||||
<div class="tag-bar">
|
||||
<button
|
||||
class="tag-pill"
|
||||
:class="{ 'tag-pill--active': activeTag === null }"
|
||||
@click="activeTag = null"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="tag in allTags"
|
||||
:key="tag"
|
||||
class="tag-pill"
|
||||
:class="{ 'tag-pill--active': activeTag === tag }"
|
||||
@click="activeTag = activeTag === tag ? null : tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 瀑布流照片墙 -->
|
||||
<section class="section section--grid">
|
||||
<div class="container">
|
||||
<div class="masonry">
|
||||
<div v-for="(col, ci) in columns" :key="ci" class="masonry__col">
|
||||
<NuxtLink
|
||||
v-for="note in col"
|
||||
:key="note.id"
|
||||
:to="`/xiaohongshu/${note.id}`"
|
||||
class="note-card"
|
||||
>
|
||||
<div class="note-card__cover" :class="{ 'note-card__cover--wide': note.ratio === 'horizontal' }">
|
||||
<img
|
||||
:src="note.cover"
|
||||
:alt="note.title"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="note-card__overlay">
|
||||
<span class="note-card__read">查看原文</span>
|
||||
</div>
|
||||
<div class="note-card__tags">
|
||||
<span v-for="tag in note.tags.slice(0, 2)" :key="tag" class="note-card__tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note-card__body">
|
||||
<h3 class="note-card__title">{{ note.title }}</h3>
|
||||
<div class="note-card__footer">
|
||||
<span class="note-card__author">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" opacity="0.4"><circle cx="12" cy="8" r="4"/><path d="M12 14c-6 0-8 3-8 3v1h16v-1s-2-3-8-3z"/></svg>
|
||||
{{ note.author }}
|
||||
</span>
|
||||
<span class="note-card__likes">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#ff2442"/></svg>
|
||||
{{ formatLikes(note.likes) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-footer">
|
||||
<p class="grid-footer__notice">以上内容来自小红书平台客户自发分享,点击卡片查看详情并跳转原文</p>
|
||||
<a
|
||||
:href="wallData.account.url"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
class="grid-footer__more"
|
||||
>
|
||||
在小红书查看更多
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<CommonSectionCTA
|
||||
title="想拥有同款草原体验?"
|
||||
description="联系定制师,开始规划您的呼伦贝尔之旅"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看旅行产品' },
|
||||
{ to: '/contact', text: '联系定制师' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: wallData } = await usePublicApi('xiaohongshu-wall')
|
||||
|
||||
const seo = useSEO('xiaohongshu')
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '小红书游记' },
|
||||
])
|
||||
|
||||
const activeTag = ref(null)
|
||||
|
||||
// 提取高频标签(按出现次数排序,最多显示8个)
|
||||
const allTags = computed(() => {
|
||||
const tagCount = {}
|
||||
;(wallData.value?.notes || []).forEach(n => n.tags?.forEach(t => {
|
||||
tagCount[t] = (tagCount[t] || 0) + 1
|
||||
}))
|
||||
return Object.entries(tagCount)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([tag]) => tag)
|
||||
})
|
||||
|
||||
// 筛选笔记
|
||||
const filteredNotes = computed(() => {
|
||||
if (!activeTag.value) return wallData.value?.notes || []
|
||||
return (wallData.value?.notes || []).filter(n => n.tags?.includes(activeTag.value))
|
||||
})
|
||||
|
||||
// 瀑布流分列 - 移动2列,桌面3列(通过CSS控制第3列显隐)
|
||||
const columns = computed(() => {
|
||||
const cols = [[], [], []]
|
||||
filteredNotes.value.forEach((note, i) => {
|
||||
cols[i % 3].push(note)
|
||||
})
|
||||
return cols
|
||||
})
|
||||
|
||||
function formatLikes(num) {
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + 'w'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
|
||||
return num
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.closest('.note-card__cover').classList.add('note-card__cover--fallback')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 账号名片区 =====
|
||||
.section--account {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-top: -@space-xl;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-xl;
|
||||
box-shadow: @shadow-lg;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.account-card__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: @space-lg @space-xl;
|
||||
gap: @space-md;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.respond-md({
|
||||
padding: @space-xl @space-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
.account-card__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
gap: @space-lg;
|
||||
});
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.respond-md({
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
});
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid @color-bg-white;
|
||||
box-shadow: 0 2px 12px rgba(255, 36, 66, 0.15);
|
||||
background: @color-bg-white;
|
||||
}
|
||||
}
|
||||
|
||||
.account-avatar__badge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #ff2442;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
border: 2px solid @color-bg-white;
|
||||
|
||||
.respond-md({
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
});
|
||||
}
|
||||
|
||||
.account-meta__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: @space-xs;
|
||||
|
||||
h2 {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-xl;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
.account-meta__verified {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
background: linear-gradient(135deg, #ff2442 0%, #ff6b81 100%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: @border-radius-full;
|
||||
font-weight: @font-weight-medium;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.account-meta__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-item__value {
|
||||
font-size: @font-size-md;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
line-height: 1;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-lg;
|
||||
});
|
||||
}
|
||||
|
||||
.stat-item__label {
|
||||
font-size: 11px;
|
||||
color: @color-text-muted;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: @color-border;
|
||||
}
|
||||
|
||||
.follow-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
padding: 10px @space-xl;
|
||||
background: linear-gradient(135deg, #ff2442 0%, #ff6b81 100%);
|
||||
color: #fff;
|
||||
border-radius: @border-radius-full;
|
||||
text-decoration: none;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
transition: all @transition-base;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 16px rgba(255, 36, 66, 0.25);
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 24px rgba(255, 36, 66, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 标签筛选 =====
|
||||
.section--tags {
|
||||
padding-top: @space-xl;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tag-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
padding: 6px 18px;
|
||||
border: 1.5px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
background: @color-bg-white;
|
||||
color: @color-text-secondary;
|
||||
font-size: @font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all @transition-fast;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-text-muted;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: @color-primary;
|
||||
border-color: @color-primary;
|
||||
color: #fff;
|
||||
font-weight: @font-weight-medium;
|
||||
|
||||
&:hover {
|
||||
background: @color-primary-dark;
|
||||
border-color: @color-primary-dark;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 瀑布流网格 =====
|
||||
.section--grid {
|
||||
padding-top: @space-xl;
|
||||
}
|
||||
|
||||
.masonry {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
gap: @space-lg;
|
||||
});
|
||||
}
|
||||
|
||||
.masonry__col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
gap: @space-lg;
|
||||
});
|
||||
|
||||
// 移动端隐藏第3列
|
||||
&:nth-child(3) {
|
||||
display: none;
|
||||
|
||||
.respond-md({
|
||||
display: flex;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 笔记卡片(精致版) =====
|
||||
.note-card {
|
||||
display: block;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
box-shadow: @shadow-sm;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-card-hover;
|
||||
transform: translateY(-4px);
|
||||
|
||||
.note-card__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note-card__cover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.note-card__cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 3 / 4;
|
||||
background: @gradient-cool;
|
||||
|
||||
&--wide {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
&--fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: '暂无封面';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
background: @gradient-cool;
|
||||
}
|
||||
|
||||
img {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
}
|
||||
|
||||
.note-card__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 50%);
|
||||
opacity: 0;
|
||||
transition: opacity @transition-base;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.note-card__read {
|
||||
padding: 6px 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.note-card__tags {
|
||||
position: absolute;
|
||||
top: @space-sm;
|
||||
left: @space-sm;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.note-card__tag {
|
||||
padding: 3px 10px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: @border-radius-full;
|
||||
font-size: 11px;
|
||||
color: @color-text-secondary;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
.note-card__body {
|
||||
padding: 14px @space-md;
|
||||
}
|
||||
|
||||
.note-card__title {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 10px;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
|
||||
.note-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.note-card__author {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: @color-text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.note-card__likes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
color: @color-text-muted;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 底部 =====
|
||||
.grid-footer {
|
||||
text-align: center;
|
||||
margin-top: @space-3xl;
|
||||
padding-top: @space-xl;
|
||||
border-top: 1px solid @color-divider;
|
||||
}
|
||||
|
||||
.grid-footer__notice {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
margin-bottom: @space-lg;
|
||||
}
|
||||
|
||||
.grid-footer__more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
padding: 12px @space-2xl;
|
||||
border: 2px solid #ff2442;
|
||||
color: #ff2442;
|
||||
border-radius: @border-radius-full;
|
||||
text-decoration: none;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
transition: all @transition-base;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
svg {
|
||||
transition: transform @transition-fast;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #ff2442;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(255, 36, 66, 0.25);
|
||||
|
||||
svg {
|
||||
stroke: #fff;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div v-if="note" class="page-youji-detail">
|
||||
<LayoutPageHero
|
||||
:title="note.title"
|
||||
:subtitle="`来自${note.platform}用户 @${note.author} 的旅行故事`"
|
||||
:breadcrumbs="[{ text: '客户游记', to: '/youji' }, { text: note.title }]"
|
||||
compact
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="detail-layout">
|
||||
<main class="detail-main">
|
||||
<!-- 封面 -->
|
||||
<div class="detail-cover" :class="{ 'detail-cover--wide': note.ratio === 'horizontal' }">
|
||||
<img :src="note.cover" :alt="note.title" />
|
||||
</div>
|
||||
|
||||
<!-- 正文 -->
|
||||
<article class="detail-content">
|
||||
<div class="detail-author-bar">
|
||||
<div class="detail-author-bar__left">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" opacity="0.5"><circle cx="12" cy="8" r="4"/><path d="M12 14c-6 0-8 3-8 3v1h16v-1s-2-3-8-3z"/></svg>
|
||||
<span class="detail-author-bar__name">{{ note.author }}</span>
|
||||
<span class="detail-author-bar__platform" :style="{ background: platformColor(note.platform) }">{{ note.platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="detail-content__title">{{ note.title }}</h2>
|
||||
|
||||
<div class="detail-content__body">
|
||||
<p>{{ note.summary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-tags">
|
||||
<span v-for="tag in note.tags" :key="tag" class="detail-tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 查看原文 CTA -->
|
||||
<div class="detail-cta" :style="{ '--platform-color': platformColor(note.platform) }">
|
||||
<div class="detail-cta__icon">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="var(--platform-color)" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</div>
|
||||
<div class="detail-cta__text">
|
||||
<p class="detail-cta__hint">以上为文章摘要,查看完整图文内容请前往原平台</p>
|
||||
<a
|
||||
:href="note.url"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
class="detail-cta__btn"
|
||||
:style="{ background: platformColor(note.platform) }"
|
||||
>
|
||||
在{{ note.platform }}查看原文
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 前后导航 -->
|
||||
<div class="detail-nav">
|
||||
<NuxtLink
|
||||
v-if="prevNote"
|
||||
:to="`/youji/${prevNote.id}`"
|
||||
class="detail-nav__item detail-nav__item--prev"
|
||||
>
|
||||
<span class="detail-nav__label">上一篇</span>
|
||||
<span class="detail-nav__title">{{ prevNote.title }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else />
|
||||
<NuxtLink
|
||||
v-if="nextNote"
|
||||
:to="`/youji/${nextNote.id}`"
|
||||
class="detail-nav__item detail-nav__item--next"
|
||||
>
|
||||
<span class="detail-nav__label">下一篇</span>
|
||||
<span class="detail-nav__title">{{ nextNote.title }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="detail-sidebar">
|
||||
<div class="sidebar-card sidebar-card--cta">
|
||||
<h3 class="sidebar-card__title">想要同款旅行体验?</h3>
|
||||
<p class="sidebar-card__desc">联系呼籁定制师,一对一规划专属行程</p>
|
||||
<CommonInternalLink to="/contact" text="免费咨询定制师" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-card">
|
||||
<h3 class="sidebar-card__title">更多客户游记</h3>
|
||||
<ul class="sidebar-notes">
|
||||
<li
|
||||
v-for="n in relatedNotes"
|
||||
:key="n.id"
|
||||
class="sidebar-note"
|
||||
:class="{ 'sidebar-note--active': n.id === note.id }"
|
||||
>
|
||||
<NuxtLink :to="`/youji/${n.id}`" class="sidebar-note__link">
|
||||
<img :src="n.cover" :alt="n.title" class="sidebar-note__thumb" />
|
||||
<div class="sidebar-note__info">
|
||||
<span class="sidebar-note__title">{{ n.title }}</span>
|
||||
<span class="sidebar-note__meta">
|
||||
<span :style="{ color: platformColor(n.platform) }">{{ n.platform }}</span> · @{{ n.author }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink to="/youji" class="sidebar-card__more">查看全部游记 →</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-card">
|
||||
<h3 class="sidebar-card__title">相关内容</h3>
|
||||
<div class="sidebar-links">
|
||||
<CommonInternalLink to="/xiaohongshu" text="小红书游记" />
|
||||
<CommonInternalLink to="/reviews" text="客户评价" />
|
||||
<CommonInternalLink to="/products" text="旅行产品详情" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CommonSectionCTA
|
||||
title="想拥有同款草原体验?"
|
||||
description="联系定制师,开始规划您的呼伦贝尔之旅"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看旅行产品' },
|
||||
{ to: '/contact', text: '联系定制师' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="page-not-found">
|
||||
<div class="container">
|
||||
<h1>游记不存在</h1>
|
||||
<NuxtLink to="/youji">返回客户游记</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: youjiData } = await usePublicApi('youji')
|
||||
|
||||
const route = useRoute()
|
||||
const allNotes = computed(() => youjiData.value?.notes || [])
|
||||
const note = computed(() => allNotes.value.find(n => n.id === route.params.id))
|
||||
|
||||
if (!note.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: '游记不存在' })
|
||||
}
|
||||
|
||||
const platformColors = {
|
||||
'美篇': '#e4393c',
|
||||
'微信公众号': '#07c160',
|
||||
'抖音': '#161823',
|
||||
'微博': '#ff8200',
|
||||
}
|
||||
function platformColor(name) {
|
||||
return platformColors[name] || '#666'
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: `${note.value.title} - 客户游记 | 呼籁旅行`,
|
||||
meta: [
|
||||
{ name: 'description', content: note.value.summary.slice(0, 160) },
|
||||
{ name: 'keywords', content: `呼伦贝尔,${note.value.tags.join(',')},呼籁旅行,客户游记,${note.value.platform},${note.value.author}` },
|
||||
{ name: 'author', content: note.value.author },
|
||||
{ property: 'og:title', content: note.value.title },
|
||||
{ property: 'og:description', content: note.value.summary.slice(0, 160) },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:image', content: note.value.cover },
|
||||
{ property: 'og:url', content: `https://1814.love/youji/${note.value.id}` },
|
||||
{ property: 'og:site_name', content: '呼籁旅行' },
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: `https://1814.love/youji/${note.value.id}` },
|
||||
],
|
||||
})
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '客户游记', to: '/youji' },
|
||||
{ text: note.value.title },
|
||||
])
|
||||
|
||||
const currentIndex = computed(() => allNotes.value.findIndex(n => n.id === note.value?.id))
|
||||
const prevNote = computed(() => currentIndex.value > 0 ? allNotes.value[currentIndex.value - 1] : null)
|
||||
const nextNote = computed(() => currentIndex.value < allNotes.value.length - 1 ? allNotes.value[currentIndex.value + 1] : null)
|
||||
|
||||
const relatedNotes = computed(() => {
|
||||
if (!note.value) return []
|
||||
const sameTag = allNotes.value.filter(n => n.id !== note.value.id && n.tags.some(t => note.value.tags.includes(t)))
|
||||
const others = allNotes.value.filter(n => n.id !== note.value.id && !sameTag.includes(n))
|
||||
return [...sameTag, ...others].slice(0, 5)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @space-2xl;
|
||||
.respond-lg({ grid-template-columns: 1fr 320px; align-items: start; });
|
||||
}
|
||||
|
||||
.detail-main { min-width: 0; }
|
||||
|
||||
.detail-cover {
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
margin-bottom: @space-xl;
|
||||
aspect-ratio: 3 / 4;
|
||||
max-height: 560px;
|
||||
background: @gradient-cool;
|
||||
&--wide { aspect-ratio: 4 / 3; max-height: 480px; }
|
||||
img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
}
|
||||
|
||||
.detail-content { margin-bottom: @space-xl; }
|
||||
|
||||
.detail-author-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: @space-lg;
|
||||
padding-bottom: @space-md;
|
||||
border-bottom: 1px solid @color-divider;
|
||||
|
||||
&__left { display: flex; align-items: center; gap: @space-sm; }
|
||||
&__name { font-size: @font-size-sm; font-weight: @font-weight-medium; color: @color-text-secondary; }
|
||||
&__platform {
|
||||
padding: 2px 10px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: @border-radius-full;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content__title {
|
||||
font-size: @font-size-2xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-lg;
|
||||
line-height: @line-height-tight;
|
||||
.respond-md({ font-size: @font-size-3xl; });
|
||||
}
|
||||
|
||||
.detail-content__body {
|
||||
p {
|
||||
font-size: @font-size-base;
|
||||
color: @color-text-secondary;
|
||||
line-height: @line-height-relaxed;
|
||||
margin: 0 0 @space-md;
|
||||
.respond-md({ font-size: @font-size-md; });
|
||||
}
|
||||
}
|
||||
|
||||
.detail-tags { display: flex; flex-wrap: wrap; gap: @space-sm; margin-top: @space-lg; }
|
||||
.detail-tag {
|
||||
padding: 4px 14px;
|
||||
background: @color-primary-bg;
|
||||
color: @color-primary;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
.detail-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-lg;
|
||||
padding: @space-xl;
|
||||
background: @color-bg-gray;
|
||||
border-radius: @border-radius-lg;
|
||||
border: 1px solid @color-border;
|
||||
margin-bottom: @space-xl;
|
||||
|
||||
&__icon { flex-shrink: 0; }
|
||||
&__hint { font-size: @font-size-sm; color: @color-text-muted; margin: 0 0 @space-sm; }
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: @space-sm;
|
||||
padding: 10px @space-xl;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-full;
|
||||
text-decoration: none;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
transition: all @transition-base;
|
||||
white-space: nowrap;
|
||||
|
||||
svg { transition: transform @transition-fast; }
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
svg { transform: translateX(3px); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: @space-md;
|
||||
padding-top: @space-lg;
|
||||
border-top: 1px solid @color-border;
|
||||
|
||||
&__item {
|
||||
padding: @space-md;
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
transition: all @transition-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-xs;
|
||||
&:hover { border-color: @color-primary; background: @color-primary-bg; }
|
||||
&--next { text-align: right; }
|
||||
}
|
||||
&__label { font-size: @font-size-xs; color: @color-text-muted; }
|
||||
&__title { font-size: @font-size-sm; color: @color-text-primary; line-height: @line-height-tight; .text-clamp(2); }
|
||||
}
|
||||
|
||||
.detail-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-lg;
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
background: @color-bg-white;
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @border-radius-lg;
|
||||
padding: @space-lg;
|
||||
|
||||
&--cta { background: @color-primary-bg; border-color: @color-primary-lighter; }
|
||||
&__title { font-size: @font-size-base; font-weight: @font-weight-bold; color: @color-text-primary; margin: 0 0 @space-sm; }
|
||||
&__desc { font-size: @font-size-sm; color: @color-text-secondary; line-height: @line-height-base; margin: 0 0 @space-md; }
|
||||
&__more {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: @space-md;
|
||||
padding-top: @space-md;
|
||||
border-top: 1px solid @color-divider;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-primary;
|
||||
text-decoration: none;
|
||||
font-weight: @font-weight-medium;
|
||||
&:hover { color: @color-primary-dark; }
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: @space-sm; }
|
||||
.sidebar-note {
|
||||
&--active .sidebar-note__link { background: @color-primary-bg; }
|
||||
&__link {
|
||||
display: flex;
|
||||
gap: @space-sm;
|
||||
padding: @space-sm;
|
||||
border-radius: @border-radius-md;
|
||||
text-decoration: none;
|
||||
transition: background @transition-fast;
|
||||
&:hover { background: @color-bg-gray; }
|
||||
}
|
||||
&__thumb { width: 48px; height: 48px; border-radius: @border-radius-sm; object-fit: cover; flex-shrink: 0; }
|
||||
&__info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
&__title { font-size: @font-size-sm; color: @color-text-primary; line-height: @line-height-tight; .text-clamp(2); }
|
||||
&__meta { font-size: 11px; color: @color-text-muted; }
|
||||
}
|
||||
|
||||
.sidebar-links { display: flex; flex-direction: column; gap: @space-sm; }
|
||||
|
||||
.page-not-found {
|
||||
text-align: center;
|
||||
padding: @space-3xl 0;
|
||||
h1 { margin-bottom: @space-lg; }
|
||||
a { color: @color-primary; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="page-youji">
|
||||
<LayoutPageHero
|
||||
:title="seo.h1 || '客户游记'"
|
||||
subtitle="来自各平台的真实旅行故事,点击查看详情"
|
||||
:breadcrumbs="[{ text: '客户游记' }]"
|
||||
background-image="/images/summer-camp/river-banner.jpg"
|
||||
/>
|
||||
|
||||
<!-- TL;DR -->
|
||||
<section class="page-tldr" aria-label="客户游记摘要">
|
||||
<div class="container">
|
||||
<div class="page-tldr-inner">
|
||||
<p>呼籁旅行客户游记合集:来自美篇、微信公众号、抖音等多平台的真实旅行故事,涵盖亲子游、家庭游、闺蜜游等多种出行方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 统计横幅 -->
|
||||
<section v-if="youjiData" class="section section--stats">
|
||||
<div class="container">
|
||||
<div class="stats-bar">
|
||||
<div class="stats-bar__item">
|
||||
<span class="stats-bar__value">{{ youjiData.notes.length }}</span>
|
||||
<span class="stats-bar__label">篇客户游记</span>
|
||||
</div>
|
||||
<span class="stats-bar__divider"></span>
|
||||
<div class="stats-bar__item">
|
||||
<span class="stats-bar__value">{{ allPlatforms.length }}</span>
|
||||
<span class="stats-bar__label">个来源平台</span>
|
||||
</div>
|
||||
<span class="stats-bar__divider"></span>
|
||||
<div class="stats-bar__item">
|
||||
<span class="stats-bar__value">100%</span>
|
||||
<span class="stats-bar__label">真实客户分享</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<section class="section section--tags">
|
||||
<div class="container">
|
||||
<!-- 平台筛选 -->
|
||||
<div class="filter-row">
|
||||
<span class="filter-label">平台</span>
|
||||
<div class="tag-bar">
|
||||
<button
|
||||
class="tag-pill"
|
||||
:class="{ 'tag-pill--active': activePlatform === null }"
|
||||
@click="activePlatform = null"
|
||||
>全部</button>
|
||||
<button
|
||||
v-for="p in allPlatforms"
|
||||
:key="p"
|
||||
class="tag-pill tag-pill--platform"
|
||||
:class="{ 'tag-pill--active': activePlatform === p }"
|
||||
:style="activePlatform === p ? { background: platformColor(p), borderColor: platformColor(p) } : {}"
|
||||
@click="activePlatform = activePlatform === p ? null : p"
|
||||
>{{ p }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 标签筛选 -->
|
||||
<div class="filter-row">
|
||||
<span class="filter-label">主题</span>
|
||||
<div class="tag-bar">
|
||||
<button
|
||||
class="tag-pill"
|
||||
:class="{ 'tag-pill--active': activeTag === null }"
|
||||
@click="activeTag = null"
|
||||
>全部</button>
|
||||
<button
|
||||
v-for="tag in allTags"
|
||||
:key="tag"
|
||||
class="tag-pill"
|
||||
:class="{ 'tag-pill--active': activeTag === tag }"
|
||||
@click="activeTag = activeTag === tag ? null : tag"
|
||||
>{{ tag }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 瀑布流 -->
|
||||
<section class="section section--grid">
|
||||
<div class="container">
|
||||
<div class="masonry">
|
||||
<div v-for="(col, ci) in columns" :key="ci" class="masonry__col">
|
||||
<NuxtLink
|
||||
v-for="note in col"
|
||||
:key="note.id"
|
||||
:to="`/youji/${note.id}`"
|
||||
class="note-card"
|
||||
>
|
||||
<div class="note-card__cover" :class="{ 'note-card__cover--wide': note.ratio === 'horizontal' }">
|
||||
<img :src="note.cover" :alt="note.title" loading="lazy" @error="handleImageError" />
|
||||
<div class="note-card__overlay">
|
||||
<span class="note-card__read">查看详情</span>
|
||||
</div>
|
||||
<div class="note-card__tags">
|
||||
<span v-for="tag in note.tags.slice(0, 2)" :key="tag" class="note-card__tag">{{ tag }}</span>
|
||||
</div>
|
||||
<span class="note-card__platform" :style="{ background: platformColor(note.platform) }">{{ note.platform }}</span>
|
||||
</div>
|
||||
<div class="note-card__body">
|
||||
<h3 class="note-card__title">{{ note.title }}</h3>
|
||||
<p class="note-card__excerpt">{{ note.summary.slice(0, 60) }}...</p>
|
||||
<div class="note-card__footer">
|
||||
<span class="note-card__author">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" opacity="0.4"><circle cx="12" cy="8" r="4"/><path d="M12 14c-6 0-8 3-8 3v1h16v-1s-2-3-8-3z"/></svg>
|
||||
{{ note.author }}
|
||||
</span>
|
||||
<span class="note-card__platform-text" :style="{ color: platformColor(note.platform) }">{{ note.platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-footer">
|
||||
<p class="grid-footer__notice">以上内容来自客户在各平台的自发分享,点击卡片查看详情并跳转原文</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CommonSectionCTA
|
||||
title="想拥有同款草原体验?"
|
||||
description="联系定制师,开始规划您的呼伦贝尔之旅"
|
||||
:links="[
|
||||
{ to: '/products', text: '查看旅行产品' },
|
||||
{ to: '/contact', text: '联系定制师' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||||
|
||||
const { data: youjiData } = await usePublicApi('youji')
|
||||
|
||||
const seo = useSEO('youji')
|
||||
|
||||
useBreadcrumbSchema([
|
||||
{ text: '首页', to: '/' },
|
||||
{ text: '客户游记' },
|
||||
])
|
||||
|
||||
const activeTag = ref(null)
|
||||
const activePlatform = ref(null)
|
||||
|
||||
const platformColors = {
|
||||
'美篇': '#e4393c',
|
||||
'微信公众号': '#07c160',
|
||||
'抖音': '#161823',
|
||||
'微博': '#ff8200',
|
||||
}
|
||||
function platformColor(name) {
|
||||
return platformColors[name] || '#666'
|
||||
}
|
||||
|
||||
const allPlatforms = computed(() => [...new Set((youjiData.value?.notes || []).map(n => n.platform))])
|
||||
|
||||
const allTags = computed(() => {
|
||||
const tagCount = {}
|
||||
;(youjiData.value?.notes || []).forEach(n => n.tags?.forEach(t => {
|
||||
tagCount[t] = (tagCount[t] || 0) + 1
|
||||
}))
|
||||
return Object.entries(tagCount)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([tag]) => tag)
|
||||
})
|
||||
|
||||
const filteredNotes = computed(() => {
|
||||
let notes = youjiData.value?.notes || []
|
||||
if (activePlatform.value) notes = notes.filter(n => n.platform === activePlatform.value)
|
||||
if (activeTag.value) notes = notes.filter(n => n.tags?.includes(activeTag.value))
|
||||
return notes
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = [[], [], []]
|
||||
filteredNotes.value.forEach((note, i) => {
|
||||
cols[i % 3].push(note)
|
||||
})
|
||||
return cols
|
||||
})
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.closest('.note-card__cover').classList.add('note-card__cover--fallback')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// ===== 统计横幅 =====
|
||||
.section--stats {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-top: -@space-xl;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: @space-xl;
|
||||
padding: @space-lg @space-xl;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-xl;
|
||||
box-shadow: @shadow-lg;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.respond-md({
|
||||
padding: @space-xl @space-2xl;
|
||||
gap: @space-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
.stats-bar__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stats-bar__value {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-primary;
|
||||
|
||||
.respond-md({
|
||||
font-size: @font-size-2xl;
|
||||
});
|
||||
}
|
||||
|
||||
.stats-bar__label {
|
||||
font-size: 12px;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.stats-bar__divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: @color-border;
|
||||
}
|
||||
|
||||
// ===== 筛选栏 =====
|
||||
.section--tags {
|
||||
padding-top: @space-xl;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @space-md;
|
||||
margin-bottom: @space-md;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
font-weight: @font-weight-medium;
|
||||
white-space: nowrap;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.tag-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: @space-sm;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
padding: 6px 18px;
|
||||
border: 1.5px solid @color-border;
|
||||
border-radius: @border-radius-full;
|
||||
background: @color-bg-white;
|
||||
color: @color-text-secondary;
|
||||
font-size: @font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all @transition-fast;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-text-muted;
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: @color-primary;
|
||||
border-color: @color-primary;
|
||||
color: #fff;
|
||||
font-weight: @font-weight-medium;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 瀑布流 =====
|
||||
.section--grid {
|
||||
padding-top: @space-xl;
|
||||
}
|
||||
|
||||
.masonry {
|
||||
display: flex;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
gap: @space-lg;
|
||||
});
|
||||
}
|
||||
|
||||
.masonry__col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @space-md;
|
||||
|
||||
.respond-md({
|
||||
gap: @space-lg;
|
||||
});
|
||||
|
||||
&:nth-child(3) {
|
||||
display: none;
|
||||
.respond-md({ display: flex; });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 卡片 =====
|
||||
.note-card {
|
||||
display: block;
|
||||
background: @color-bg-white;
|
||||
border-radius: @border-radius-lg;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
box-shadow: @shadow-sm;
|
||||
transition: all @transition-base;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-card-hover;
|
||||
transform: translateY(-4px);
|
||||
.note-card__overlay { opacity: 1; }
|
||||
.note-card__cover img { transform: scale(1.05); }
|
||||
}
|
||||
}
|
||||
|
||||
.note-card__cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 3 / 4;
|
||||
background: @gradient-cool;
|
||||
|
||||
&--wide { aspect-ratio: 4 / 3; }
|
||||
|
||||
&--fallback {
|
||||
&::after {
|
||||
content: '暂无封面';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
background: @gradient-cool;
|
||||
}
|
||||
img { display: none; }
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
}
|
||||
|
||||
.note-card__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 50%);
|
||||
opacity: 0;
|
||||
transition: opacity @transition-base;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-bottom: @space-xl;
|
||||
}
|
||||
|
||||
.note-card__read {
|
||||
padding: 6px 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: @border-radius-full;
|
||||
font-size: @font-size-xs;
|
||||
font-weight: @font-weight-medium;
|
||||
color: @color-text-primary;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.note-card__tags {
|
||||
position: absolute;
|
||||
top: @space-sm;
|
||||
left: @space-sm;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.note-card__tag {
|
||||
padding: 3px 10px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: @border-radius-full;
|
||||
font-size: 11px;
|
||||
color: @color-text-secondary;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
.note-card__platform {
|
||||
position: absolute;
|
||||
top: @space-sm;
|
||||
right: @space-sm;
|
||||
padding: 3px 10px;
|
||||
color: #fff;
|
||||
border-radius: @border-radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: @font-weight-bold;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.note-card__body {
|
||||
padding: 14px @space-md;
|
||||
}
|
||||
|
||||
.note-card__title {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: @font-weight-bold;
|
||||
color: @color-text-primary;
|
||||
margin: 0 0 @space-sm;
|
||||
line-height: @line-height-tight;
|
||||
.text-clamp(2);
|
||||
}
|
||||
|
||||
.note-card__excerpt {
|
||||
font-size: 12px;
|
||||
color: @color-text-muted;
|
||||
line-height: @line-height-normal;
|
||||
margin: 0 0 10px;
|
||||
.text-clamp(2);
|
||||
}
|
||||
|
||||
.note-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.note-card__author {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: @color-text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.note-card__platform-text {
|
||||
font-size: 11px;
|
||||
font-weight: @font-weight-medium;
|
||||
}
|
||||
|
||||
// ===== 底部 =====
|
||||
.grid-footer {
|
||||
text-align: center;
|
||||
margin-top: @space-3xl;
|
||||
padding-top: @space-xl;
|
||||
border-top: 1px solid @color-divider;
|
||||
}
|
||||
|
||||
.grid-footer__notice {
|
||||
font-size: @font-size-sm;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
</style>
|
||||
在新工单中引用
屏蔽一个用户