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>
这个提交包含在:
Mimingguang
2026-03-24 17:48:02 +08:00
共同撰写人 Claude Opus 4.6
当前提交 ee078b61bc
共修改 444 个文件,包含 59890 行新增和 0 行删除
+390
查看文件
@@ -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>
+456
查看文件
@@ -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>