hulai-website/pages/gallery.vue
刘涛 a25f3a77ce sync: 同步最新呼籁官网代码到仓库
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 21:57:35 +08:00

615 行
15 KiB
Vue

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

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

<template>
<div class="page-gallery">
<!-- 全屏 Hero用第一张照片做背景 -->
<section class="gallery-hero">
<div class="gallery-hero__bg">
<img
:src="gallery.works[7].image"
alt="呼籁草原旅拍"
class="gallery-hero__img"
/>
<div class="gallery-hero__overlay" />
</div>
<div class="gallery-hero__content">
<nav class="gallery-hero__breadcrumb" aria-label="面包屑导航">
<NuxtLink to="/">首页</NuxtLink>
<span>/</span>
<span>旅拍作品</span>
</nav>
<h1 class="gallery-hero__title">呼籁旅拍</h1>
<p class="gallery-hero__subtitle">在呼籁营地用蒙古袍定格草原最美的你</p>
<div class="gallery-hero__stats">
<div v-for="stat in gallery.intro.stats" :key="stat.label" class="gallery-hero__stat">
<span class="gallery-hero__stat-value">{{ stat.value }}</span>
<span class="gallery-hero__stat-label">{{ stat.label }}</span>
</div>
</div>
</div>
<div class="gallery-hero__scroll-hint" aria-hidden="true">
<span>向下滑动浏览作品</span>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 4v12M4 10l6 6 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</section>
<!-- TL;DR -->
<section class="page-tldr" aria-label="旅拍作品摘要">
<div class="container">
<div class="page-tldr-inner">
<p>呼籁旅行在自营营地配备专属旅拍摄影团队拥有200+件蒙古族服饰传统袍服节日盛装亲子套装旅拍在呼籁营地进行无需单独预约每位出行客人均可体验获得专业摄影师拍摄的草原珍贵瞬间</p>
</div>
</div>
</section>
<!-- 作品瀑布流 -->
<section class="gallery-section">
<div class="container">
<div class="gallery-section__header">
<h2 class="gallery-section__title">旅拍作品集</h2>
<p class="gallery-section__desc">每一张都是真实客户在呼籁营地的草原记忆</p>
</div>
<!-- 瀑布流 -->
<div class="gallery-masonry">
<div
v-for="(work, index) in gallery.works"
:key="work.id"
class="gallery-item reveal"
:class="`gallery-item--${work.orientation}`"
@click="openLightbox(index)"
>
<div class="gallery-card">
<img
:src="work.image"
:alt="`${work.title} - 呼籁营地旅拍`"
class="gallery-card__img"
loading="lazy"
/>
<div class="gallery-card__hover">
<div class="gallery-card__hover-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Lightbox 灯箱 -->
<Teleport to="body">
<Transition name="lightbox">
<div
v-if="lightboxOpen"
class="lightbox"
@click.self="closeLightbox"
@keydown.esc="closeLightbox"
@keydown.left="prevPhoto"
@keydown.right="nextPhoto"
tabindex="0"
ref="lightboxRef"
>
<button class="lightbox__close" @click="closeLightbox" aria-label="关闭">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<button class="lightbox__nav lightbox__nav--prev" @click="prevPhoto" aria-label="上一张">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="lightbox__img-wrap">
<img
:src="gallery.works[lightboxIndex].image"
:alt="gallery.works[lightboxIndex].title"
class="lightbox__img"
/>
</div>
<button class="lightbox__nav lightbox__nav--next" @click="nextPhoto" aria-label="下一张">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="lightbox__counter">
{{ lightboxIndex + 1 }} / {{ gallery.works.length }}
</div>
</div>
</Transition>
</Teleport>
<!-- 旅拍说明 -->
<section class="gallery-about section">
<div class="container">
<CommonSectionTitle
title="关于呼籁旅拍"
subtitle="记录真实的草原瞬间,而不是摆拍"
align="center"
/>
<div class="gallery-about__content">
<p class="gallery-about__lead">我们追求的是在游玩和互动过程中捕捉最自然最真实的瞬间不做浓妆不做刻意摆拍的重民族风草原本身就是最好的背景真实的笑容比任何姿势都好看</p>
<div class="gallery-about__details">
<div class="gallery-about__item">
<h3>提前沟通拍出你想要的</h3>
<p>如果你对旅拍有自己的想法和偏好可以提前和我们的摄影师沟通我们会根据你的需求在拍摄时更有针对性地抓拍</p>
</div>
<div class="gallery-about__item">
<h3>妆容自由丰俭由人</h3>
<p>旅拍默认不含化妆因为每个人的肤质不同适合的化妆品也不一样建议出发前自己画个淡妆配上草原的蓝天绿草就非常好看营地有专业化妆师需要的话可以现场预约明码标价费用透明</p>
</div>
<div class="gallery-about__item">
<h3>底片全送没有隐形消费</h3>
<p>所有底片全部赠送不存在选片环节的加价收到照片后如果有任何想法第一时间联系我们每个人对好看的理解不同我们愿意倾听你的反馈一起磨合出更满意的作品</p>
</div>
</div>
</div>
</div>
</section>
<!-- 预约旅拍 CTA -->
<CommonSectionCTA
title="预约您的草原旅拍"
description="旅拍在呼籁营地进行,随行程一并安排,无需单独预约。有任何拍摄想法,可以提前和摄影师沟通。"
:links="[
{ to: '/customize', text: '立即预约,开始定制行程' },
{ to: '/contact', text: '咨询旅拍详情' },
]"
/>
</div>
</template>
<script setup>
import galleryData from '~/data/gallery.json'
import { useBreadcrumbSchema, useGallerySchema } from '~/composables/useJsonLd'
import useScrollReveal from '~/composables/useScrollReveal'
const seo = useSEO('gallery')
useScrollReveal()
const gallery = galleryData
// Lightbox
const lightboxOpen = ref(false)
const lightboxIndex = ref(0)
const lightboxRef = ref(null)
function openLightbox(index) {
lightboxIndex.value = index
lightboxOpen.value = true
document.body.style.overflow = 'hidden'
nextTick(() => {
lightboxRef.value?.focus()
})
}
function closeLightbox() {
lightboxOpen.value = false
document.body.style.overflow = ''
}
function nextPhoto() {
lightboxIndex.value = (lightboxIndex.value + 1) % gallery.works.length
}
function prevPhoto() {
lightboxIndex.value = (lightboxIndex.value - 1 + gallery.works.length) % gallery.works.length
}
// 结构化数据
useBreadcrumbSchema([
{ text: '首页', to: '/' },
{ text: '旅拍作品' },
])
useGallerySchema(gallery)
</script>
<style lang="less" scoped>
// ========== Hero ==========
.gallery-hero {
position: relative;
height: 100vh;
min-height: 600px;
max-height: 1000px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
&__bg {
position: absolute;
inset: 0;
z-index: 0;
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 60%;
}
&__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
&__content {
position: relative;
z-index: 1;
text-align: center;
color: #fff !important;
padding: 0 @space-lg;
* {
color: #fff !important;
}
}
&__breadcrumb {
font-size: @font-size-sm;
margin-bottom: @space-lg;
opacity: 0.85;
a {
color: #fff !important;
text-decoration: none;
&:hover { text-decoration: underline; }
}
span { margin: 0 @space-xs; }
}
&__title {
font-size: 48px;
font-weight: @font-weight-bold;
letter-spacing: 8px;
margin: 0 0 @space-md;
color: #fff !important;
text-shadow: 0 2px 40px rgba(0, 0, 0, 0.6), 0 1px 8px rgba(0, 0, 0, 0.4);
.respond-md({
font-size: 64px;
letter-spacing: 12px;
});
}
&__subtitle {
font-size: @font-size-lg;
color: rgba(255, 255, 255, 0.95) !important;
margin: 0 0 @space-2xl;
font-weight: 300;
letter-spacing: 2px;
text-shadow: 0 1px 10px rgba(0, 0, 0, 0.5);
.respond-md({
font-size: @font-size-xl;
});
}
&__stats {
display: flex;
justify-content: center;
gap: @space-xl;
flex-wrap: wrap;
.respond-md({
gap: @space-3xl;
});
}
&__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
&__stat-value {
font-size: @font-size-2xl;
font-weight: @font-weight-bold;
line-height: 1.2;
.respond-md({
font-size: 32px;
});
}
&__stat-label {
font-size: @font-size-xs;
opacity: 0.8;
letter-spacing: 1px;
}
&__scroll-hint {
position: absolute;
bottom: @space-xl;
left: 50%;
transform: translateX(-50%);
z-index: 1;
color: #fff;
opacity: 0.6;
display: flex;
flex-direction: column;
align-items: center;
gap: @space-xs;
font-size: @font-size-xs;
letter-spacing: 1px;
animation: bounce 2s ease-in-out infinite;
svg {
animation: bounce-arrow 2s ease-in-out infinite;
}
}
}
@keyframes bounce {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-4px); }
}
@keyframes bounce-arrow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(4px); }
}
// ========== Gallery Section ==========
.gallery-section {
padding: @space-3xl 0;
.respond-md({
padding: 80px 0;
});
&__header {
text-align: center;
margin-bottom: @space-2xl;
.respond-md({
margin-bottom: @space-3xl;
});
}
&__title {
font-size: @font-size-2xl;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-sm;
letter-spacing: 2px;
.respond-md({
font-size: 32px;
});
}
&__desc {
font-size: @font-size-base;
color: @color-text-secondary;
margin: 0;
}
}
// ========== Masonry ==========
.gallery-masonry {
columns: 2;
column-gap: @space-sm;
.respond-md({
columns: 2;
column-gap: @space-md;
});
.respond-lg({
columns: 3;
column-gap: @space-lg;
});
}
.gallery-item {
break-inside: avoid;
margin-bottom: @space-sm;
cursor: pointer;
.respond-md({
margin-bottom: @space-md;
});
.respond-lg({
margin-bottom: @space-lg;
});
}
// ========== Card ==========
.gallery-card {
position: relative;
border-radius: @border-radius-md;
overflow: hidden;
background: @color-bg-gray;
.respond-md({
border-radius: @border-radius-lg;
});
&__img {
display: block;
width: 100%;
height: auto;
transition: transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
&__hover {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s ease;
}
&__hover-icon {
color: #fff;
opacity: 0;
transform: scale(0.8);
transition: all 0.3s ease;
}
&:hover {
.gallery-card__img {
transform: scale(1.05);
}
.gallery-card__hover {
background: rgba(0, 0, 0, 0.25);
}
.gallery-card__hover-icon {
opacity: 1;
transform: scale(1);
}
}
}
// ========== About Section ==========
.gallery-about {
&__content {
max-width: 800px;
margin: 0 auto;
}
&__lead {
text-align: center;
font-size: @font-size-md;
color: @color-text-secondary;
line-height: @line-height-relaxed;
margin: @space-lg auto @space-2xl;
max-width: 680px;
}
&__details {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(3, 1fr);
});
}
&__item {
padding: @space-lg;
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
h3 {
font-size: @font-size-base;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-sm;
}
p {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-relaxed;
margin: 0;
}
}
}
</style>
<!-- Lightbox 样式必须 unscoped因为 Teleport DOM 移到 body -->
<style lang="less">
.lightbox {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.96);
display: flex;
align-items: center;
justify-content: center;
outline: none;
&__close {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
background: none;
border: none;
color: #fff;
cursor: pointer;
padding: 8px;
opacity: 0.7;
transition: opacity 0.2s;
&:hover { opacity: 1; }
}
&__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
cursor: pointer;
padding: 16px;
border-radius: 50%;
opacity: 0.6;
transition: all 0.2s;
&:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.2);
}
&--prev { left: 20px; }
&--next { right: 20px; }
}
&__img-wrap {
max-width: 90vw;
max-height: 85vh;
display: flex;
align-items: center;
justify-content: center;
}
&__img {
max-width: 100%;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
}
&__counter {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
letter-spacing: 2px;
}
}
.lightbox-enter-active,
.lightbox-leave-active {
transition: opacity 0.3s ease;
}
.lightbox-enter-from,
.lightbox-leave-to {
opacity: 0;
}
</style>