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

390 行
13 KiB
Vue

<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> &middot; @{{ n.author }}
</span>
</div>
</NuxtLink>
</li>
</ul>
<NuxtLink to="/youji" class="sidebar-card__more">查看全部游记 &rarr;</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>