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

581 行
15 KiB
Vue

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

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

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