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

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

457 行
12 KiB
Vue

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

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

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