1. 首页大面积空白:useScrollReveal 添加 MutationObserver, 确保异步子组件的 .reveal 元素也能触发入场动画 2. 小红书/游记封面图不显示: - 小红书 CDN 签名链接过期+防盗链,全部下载到本地 - 新增 download-covers.mjs 脚本批量下载封面 - 新增 useImageProxy composable 转换外部 URL - 页面 img 标签添加 referrerpolicy="no-referrer" - 管理端保存时自动下载外部封面图到本地 - 种子数据更新为本地路径 3. 定制页微信号文字替换为二维码图片 4. prerender 路由添加 /xiaohongshu 和 /youji Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
501 行
12 KiB
Vue
501 行
12 KiB
Vue
<template>
|
||
<div class="page-youji">
|
||
<LayoutPageHero
|
||
:title="seo.h1 || '客户游记'"
|
||
subtitle="来自各平台的真实旅行故事,点击查看详情"
|
||
:breadcrumbs="[{ text: '客户游记' }]"
|
||
background-image="/images/summer-camp/river-banner.jpg"
|
||
/>
|
||
|
||
<!-- TL;DR -->
|
||
<section class="page-tldr" aria-label="客户游记摘要">
|
||
<div class="container">
|
||
<div class="page-tldr-inner">
|
||
<p>呼籁旅行客户游记合集:来自美篇、微信公众号、抖音等多平台的真实旅行故事,涵盖亲子游、家庭游、闺蜜游等多种出行方式。</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 统计横幅 -->
|
||
<section v-if="youjiData" class="section section--stats">
|
||
<div class="container">
|
||
<div class="stats-bar">
|
||
<div class="stats-bar__item">
|
||
<span class="stats-bar__value">{{ youjiData.notes.length }}</span>
|
||
<span class="stats-bar__label">篇客户游记</span>
|
||
</div>
|
||
<span class="stats-bar__divider"></span>
|
||
<div class="stats-bar__item">
|
||
<span class="stats-bar__value">{{ allPlatforms.length }}</span>
|
||
<span class="stats-bar__label">个来源平台</span>
|
||
</div>
|
||
<span class="stats-bar__divider"></span>
|
||
<div class="stats-bar__item">
|
||
<span class="stats-bar__value">100%</span>
|
||
<span class="stats-bar__label">真实客户分享</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 筛选栏 -->
|
||
<section class="section section--tags">
|
||
<div class="container">
|
||
<!-- 平台筛选 -->
|
||
<div class="filter-row">
|
||
<span class="filter-label">平台</span>
|
||
<div class="tag-bar">
|
||
<button
|
||
class="tag-pill"
|
||
:class="{ 'tag-pill--active': activePlatform === null }"
|
||
@click="activePlatform = null"
|
||
>全部</button>
|
||
<button
|
||
v-for="p in allPlatforms"
|
||
:key="p"
|
||
class="tag-pill tag-pill--platform"
|
||
:class="{ 'tag-pill--active': activePlatform === p }"
|
||
:style="activePlatform === p ? { background: platformColor(p), borderColor: platformColor(p) } : {}"
|
||
@click="activePlatform = activePlatform === p ? null : p"
|
||
>{{ p }}</button>
|
||
</div>
|
||
</div>
|
||
<!-- 标签筛选 -->
|
||
<div class="filter-row">
|
||
<span class="filter-label">主题</span>
|
||
<div class="tag-bar">
|
||
<button
|
||
class="tag-pill"
|
||
:class="{ 'tag-pill--active': activeTag === null }"
|
||
@click="activeTag = null"
|
||
>全部</button>
|
||
<button
|
||
v-for="tag in allTags"
|
||
:key="tag"
|
||
class="tag-pill"
|
||
:class="{ 'tag-pill--active': activeTag === tag }"
|
||
@click="activeTag = activeTag === tag ? null : tag"
|
||
>{{ tag }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 瀑布流 -->
|
||
<section class="section section--grid">
|
||
<div class="container">
|
||
<div class="masonry">
|
||
<div v-for="(col, ci) in columns" :key="ci" class="masonry__col">
|
||
<NuxtLink
|
||
v-for="note in col"
|
||
:key="note.id"
|
||
:to="`/youji/${note.id}`"
|
||
class="note-card"
|
||
>
|
||
<div class="note-card__cover" :class="{ 'note-card__cover--wide': note.ratio === 'horizontal' }">
|
||
<img :src="proxyImage(note.cover)" :alt="note.title" loading="lazy" referrerpolicy="no-referrer" @error="handleImageError" />
|
||
<div class="note-card__overlay">
|
||
<span class="note-card__read">查看详情</span>
|
||
</div>
|
||
<div class="note-card__tags">
|
||
<span v-for="tag in note.tags.slice(0, 2)" :key="tag" class="note-card__tag">{{ tag }}</span>
|
||
</div>
|
||
<span class="note-card__platform" :style="{ background: platformColor(note.platform) }">{{ note.platform }}</span>
|
||
</div>
|
||
<div class="note-card__body">
|
||
<h3 class="note-card__title">{{ note.title }}</h3>
|
||
<p class="note-card__excerpt">{{ note.summary.slice(0, 60) }}...</p>
|
||
<div class="note-card__footer">
|
||
<span class="note-card__author">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" opacity="0.4"><circle cx="12" cy="8" r="4"/><path d="M12 14c-6 0-8 3-8 3v1h16v-1s-2-3-8-3z"/></svg>
|
||
{{ note.author }}
|
||
</span>
|
||
<span class="note-card__platform-text" :style="{ color: platformColor(note.platform) }">{{ note.platform }}</span>
|
||
</div>
|
||
</div>
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-footer">
|
||
<p class="grid-footer__notice">以上内容来自客户在各平台的自发分享,点击卡片查看详情并跳转原文</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<CommonSectionCTA
|
||
title="想拥有同款草原体验?"
|
||
description="联系定制师,开始规划您的呼伦贝尔之旅"
|
||
:links="[
|
||
{ to: '/products', text: '查看旅行产品' },
|
||
{ to: '/contact', text: '联系定制师' },
|
||
]"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||
import { proxyImage } from '~/composables/useImageProxy'
|
||
|
||
const { data: youjiData } = await usePublicApi('youji')
|
||
|
||
const seo = useSEO('youji')
|
||
|
||
useBreadcrumbSchema([
|
||
{ text: '首页', to: '/' },
|
||
{ text: '客户游记' },
|
||
])
|
||
|
||
const activeTag = ref(null)
|
||
const activePlatform = ref(null)
|
||
|
||
const platformColors = {
|
||
'美篇': '#e4393c',
|
||
'微信公众号': '#07c160',
|
||
'抖音': '#161823',
|
||
'微博': '#ff8200',
|
||
}
|
||
function platformColor(name) {
|
||
return platformColors[name] || '#666'
|
||
}
|
||
|
||
const allPlatforms = computed(() => [...new Set((youjiData.value?.notes || []).map(n => n.platform))])
|
||
|
||
const allTags = computed(() => {
|
||
const tagCount = {}
|
||
;(youjiData.value?.notes || []).forEach(n => n.tags?.forEach(t => {
|
||
tagCount[t] = (tagCount[t] || 0) + 1
|
||
}))
|
||
return Object.entries(tagCount)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8)
|
||
.map(([tag]) => tag)
|
||
})
|
||
|
||
const filteredNotes = computed(() => {
|
||
let notes = youjiData.value?.notes || []
|
||
if (activePlatform.value) notes = notes.filter(n => n.platform === activePlatform.value)
|
||
if (activeTag.value) notes = notes.filter(n => n.tags?.includes(activeTag.value))
|
||
return notes
|
||
})
|
||
|
||
const columns = computed(() => {
|
||
const cols = [[], [], []]
|
||
filteredNotes.value.forEach((note, i) => {
|
||
cols[i % 3].push(note)
|
||
})
|
||
return cols
|
||
})
|
||
|
||
function handleImageError(e) {
|
||
e.target.closest('.note-card__cover').classList.add('note-card__cover--fallback')
|
||
}
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
// ===== 统计横幅 =====
|
||
.section--stats {
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
margin-top: -@space-xl;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
|
||
.stats-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: @space-xl;
|
||
padding: @space-lg @space-xl;
|
||
background: @color-bg-white;
|
||
border-radius: @border-radius-xl;
|
||
box-shadow: @shadow-lg;
|
||
flex-wrap: wrap;
|
||
|
||
.respond-md({
|
||
padding: @space-xl @space-2xl;
|
||
gap: @space-2xl;
|
||
});
|
||
}
|
||
|
||
.stats-bar__item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.stats-bar__value {
|
||
font-size: @font-size-xl;
|
||
font-weight: @font-weight-bold;
|
||
color: @color-primary;
|
||
|
||
.respond-md({
|
||
font-size: @font-size-2xl;
|
||
});
|
||
}
|
||
|
||
.stats-bar__label {
|
||
font-size: 12px;
|
||
color: @color-text-muted;
|
||
}
|
||
|
||
.stats-bar__divider {
|
||
width: 1px;
|
||
height: 32px;
|
||
background: @color-border;
|
||
}
|
||
|
||
// ===== 筛选栏 =====
|
||
.section--tags {
|
||
padding-top: @space-xl;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.filter-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: @space-md;
|
||
margin-bottom: @space-md;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.filter-label {
|
||
font-size: @font-size-sm;
|
||
color: @color-text-muted;
|
||
font-weight: @font-weight-medium;
|
||
white-space: nowrap;
|
||
min-width: 32px;
|
||
}
|
||
|
||
.tag-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: @space-sm;
|
||
}
|
||
|
||
.tag-pill {
|
||
padding: 6px 18px;
|
||
border: 1.5px solid @color-border;
|
||
border-radius: @border-radius-full;
|
||
background: @color-bg-white;
|
||
color: @color-text-secondary;
|
||
font-size: @font-size-sm;
|
||
cursor: pointer;
|
||
transition: all @transition-fast;
|
||
font-family: inherit;
|
||
line-height: 1.4;
|
||
|
||
&:hover {
|
||
border-color: @color-text-muted;
|
||
color: @color-text-primary;
|
||
}
|
||
|
||
&--active {
|
||
background: @color-primary;
|
||
border-color: @color-primary;
|
||
color: #fff;
|
||
font-weight: @font-weight-medium;
|
||
|
||
&:hover {
|
||
color: #fff;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 瀑布流 =====
|
||
.section--grid {
|
||
padding-top: @space-xl;
|
||
}
|
||
|
||
.masonry {
|
||
display: flex;
|
||
gap: @space-md;
|
||
|
||
.respond-md({
|
||
gap: @space-lg;
|
||
});
|
||
}
|
||
|
||
.masonry__col {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: @space-md;
|
||
|
||
.respond-md({
|
||
gap: @space-lg;
|
||
});
|
||
|
||
&:nth-child(3) {
|
||
display: none;
|
||
.respond-md({ display: flex; });
|
||
}
|
||
}
|
||
|
||
// ===== 卡片 =====
|
||
.note-card {
|
||
display: block;
|
||
background: @color-bg-white;
|
||
border-radius: @border-radius-lg;
|
||
overflow: hidden;
|
||
text-decoration: none;
|
||
box-shadow: @shadow-sm;
|
||
transition: all @transition-base;
|
||
|
||
&:hover {
|
||
box-shadow: @shadow-card-hover;
|
||
transform: translateY(-4px);
|
||
.note-card__overlay { opacity: 1; }
|
||
.note-card__cover img { transform: scale(1.05); }
|
||
}
|
||
}
|
||
|
||
.note-card__cover {
|
||
position: relative;
|
||
overflow: hidden;
|
||
aspect-ratio: 3 / 4;
|
||
background: @gradient-cool;
|
||
|
||
&--wide { aspect-ratio: 4 / 3; }
|
||
|
||
&--fallback {
|
||
&::after {
|
||
content: '暂无封面';
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: @font-size-sm;
|
||
color: @color-text-muted;
|
||
background: @gradient-cool;
|
||
}
|
||
img { display: none; }
|
||
}
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||
}
|
||
}
|
||
|
||
.note-card__overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 50%);
|
||
opacity: 0;
|
||
transition: opacity @transition-base;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
padding-bottom: @space-xl;
|
||
}
|
||
|
||
.note-card__read {
|
||
padding: 6px 20px;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: @border-radius-full;
|
||
font-size: @font-size-xs;
|
||
font-weight: @font-weight-medium;
|
||
color: @color-text-primary;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.note-card__tags {
|
||
position: absolute;
|
||
top: @space-sm;
|
||
left: @space-sm;
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.note-card__tag {
|
||
padding: 3px 10px;
|
||
background: rgba(255, 255, 255, 0.88);
|
||
backdrop-filter: blur(8px);
|
||
border-radius: @border-radius-full;
|
||
font-size: 11px;
|
||
color: @color-text-secondary;
|
||
font-weight: @font-weight-medium;
|
||
}
|
||
|
||
.note-card__platform {
|
||
position: absolute;
|
||
top: @space-sm;
|
||
right: @space-sm;
|
||
padding: 3px 10px;
|
||
color: #fff;
|
||
border-radius: @border-radius-full;
|
||
font-size: 11px;
|
||
font-weight: @font-weight-bold;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
.note-card__body {
|
||
padding: 14px @space-md;
|
||
}
|
||
|
||
.note-card__title {
|
||
font-size: @font-size-sm;
|
||
font-weight: @font-weight-bold;
|
||
color: @color-text-primary;
|
||
margin: 0 0 @space-sm;
|
||
line-height: @line-height-tight;
|
||
.text-clamp(2);
|
||
}
|
||
|
||
.note-card__excerpt {
|
||
font-size: 12px;
|
||
color: @color-text-muted;
|
||
line-height: @line-height-normal;
|
||
margin: 0 0 10px;
|
||
.text-clamp(2);
|
||
}
|
||
|
||
.note-card__footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.note-card__author {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
color: @color-text-muted;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 60%;
|
||
}
|
||
|
||
.note-card__platform-text {
|
||
font-size: 11px;
|
||
font-weight: @font-weight-medium;
|
||
}
|
||
|
||
// ===== 底部 =====
|
||
.grid-footer {
|
||
text-align: center;
|
||
margin-top: @space-3xl;
|
||
padding-top: @space-xl;
|
||
border-top: 1px solid @color-divider;
|
||
}
|
||
|
||
.grid-footer__notice {
|
||
font-size: @font-size-sm;
|
||
color: @color-text-muted;
|
||
}
|
||
</style>
|