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

295 行
7.7 KiB
Vue

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

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

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