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>
这个提交包含在:
+389
@@ -0,0 +1,389 @@
|
||||
<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> · @{{ n.author }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink to="/youji" class="sidebar-card__more">查看全部游记 →</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>
|
||||
@@ -0,0 +1,499 @@
|
||||
<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="note.cover" :alt="note.title" loading="lazy" @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'
|
||||
|
||||
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>
|
||||
在新工单中引用
屏蔽一个用户