Files
mp-pilates/packages/app/src/components/BrandBanner.vue
2026-10-25 21:15:15 +08:00

115 lines
7.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="brand-banner" :class="{ 'is-entering': animate }" :style="layoutStyle">
<view class="brand-nav">
<view class="brand-identity">
<view class="logo-circle">
<image v-if="logoImage && !logoFailed" class="logo-img" :src="logoImage" mode="aspectFill" @error="logoFailed = true" />
<text v-else class="logo-letter">{{ studioName.slice(0, 1) }}</text>
</view>
<view class="brand-copy">
<text class="studio-name">{{ studioName }}</text>
<text class="studio-category">普拉提私教</text>
</view>
</view>
</view>
<view class="welcome-copy">
<text class="date-label">{{ moment.dateLabel }}</text>
<text class="greeting" :class="{ 'greeting-long': greeting.length > 12 }">{{ greeting }}</text>
<view class="welcome-note">
<text v-if="visitorState === 'guest'">从了解身体开始,<text class="note-continuation">找到自己的练习节奏。</text></text>
<text v-else-if="visitorState === 'member'">很高兴,今天又见到你。</text>
<text v-else>今天,也留一点时间给自己。</text>
</view>
</view>
<view class="studio-photo">
<image v-if="!photoFailed" class="hero-image" :class="{ 'is-loaded': photoLoaded }" :src="heroImage"
mode="aspectFill" :fade-show="false" :draggable="false" aria-label="晨光中的普拉提核心床"
@load="photoLoaded = true" @error="photoFailed = true" />
<view v-else class="photo-fallback"><text>给自己,一段专注的练习时光。</text></view>
</view>
<view class="daily-reminder">
<view class="sun-icon" aria-hidden="true">
<view class="sun-core" />
<view v-for="ray in 4" :key="ray" class="sun-ray" :class="'ray-' + ray" />
</view>
<view class="reminder-copy">
<text class="reminder-label">今日小提醒</text>
<text class="reminder-text">{{ moment.reminder }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
import { getHomeGreeting, getHomeMoment, type HomeVisitorState } from '../utils/home'
const props = withDefaults(defineProps<{
studioInfo: StudioConfig | null
now: number
visitorState: HomeVisitorState
nickname?: string | null
statusBarHeight: number
navigationHeight: number
menuLeft: number
animate?: boolean
}>(), { animate: true, nickname: null })
// Bundled cover reproduces the approved design and is available without network.
const heroImage = '/static/home/studio-morning.jpg'
const logoFailed = ref(false)
const photoLoaded = ref(false)
const photoFailed = ref(false)
const studioName = computed(() => props.studioInfo?.name?.trim() || 'Focus Core')
const logoImage = computed(() => props.studioInfo?.logo || '')
const moment = computed(() => getHomeMoment(new Date(props.now)))
const greeting = computed(() => getHomeGreeting(moment.value.period, props.visitorState, props.nickname))
const layoutStyle = computed(() => ({
'--home-status-height': `${props.statusBarHeight}px`,
'--home-navigation-height': `${props.navigationHeight}px`,
'--home-brand-max-width': `${Math.max(100, props.menuLeft - 36)}px`,
}))
watch(logoImage, () => { logoFailed.value = false })
</script>
<style lang="scss" scoped>
.brand-banner { margin: 0 32rpx; padding-top: calc(var(--home-status-height) + var(--home-navigation-height) + 46rpx); border-bottom: 1rpx solid #e3ddd5; color: #403c36; }
.brand-nav { position: fixed; top: 0; left: 0; right: 0; z-index: 20; box-sizing: border-box; padding: var(--home-status-height) 40rpx 0; height: calc(var(--home-status-height) + var(--home-navigation-height)); display: flex; align-items: center; background: #fbf9f6; }
.brand-identity { display: flex; align-items: center; gap: 16rpx; max-width: var(--home-brand-max-width); min-width: 0; }
.logo-circle { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 76rpx; height: 76rpx; border-radius: 50%; overflow: hidden; background: #292a27; }
.logo-img { width: 100%; height: 100%; }
.logo-letter { color: #fffdf8; font-family: 'Baskerville', 'Times New Roman', serif; font-size: 49rpx; line-height: 1; }
.brand-copy { min-width: 0; }
.studio-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'Baskerville', 'Songti SC', serif; font-size: 34rpx; line-height: 1.2; color: #292a27; }
.studio-category { display: block; margin-top: 4rpx; font-size: 20rpx; line-height: 1.4; color: #8b817b; letter-spacing: 1rpx; }
.welcome-copy { padding: 0 8rpx; }
.date-label { display: block; font-size: 24rpx; line-height: 1.6; color: #8b817b; }
.greeting { display: block; margin-top: 18rpx; font-size: 64rpx; font-weight: 600; letter-spacing: -2rpx; line-height: 1.35; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.greeting-long { font-size: 54rpx; letter-spacing: -1rpx; }
.welcome-note { margin-top: 18rpx; min-height: 92rpx; font-size: 32rpx; line-height: 1.55; color: #756a60; }
.note-continuation { display: block; }
.studio-photo { position: relative; width: 100%; height: 452rpx; margin-top: 20rpx; border-radius: 36rpx; overflow: hidden; background: #e9e1d5; }
.hero-image { display: block; width: 100%; height: 100%; opacity: 0; transition: opacity .35s ease; }
.hero-image.is-loaded { opacity: 1; }
.photo-fallback { display: flex; align-items: center; justify-content: center; height: 100%; padding: 0 36rpx; box-sizing: border-box; color: #756a60; font-size: 28rpx; line-height: 1.8; }
.daily-reminder { display: flex; align-items: center; gap: 24rpx; padding: 32rpx 10rpx 42rpx; }
.reminder-copy { flex: 1; min-width: 0; }
.reminder-label { display: block; color: #8b817b; font-size: 23rpx; line-height: 1.5; }
.reminder-text { display: block; margin-top: 6rpx; color: #756a60; font-size: 25rpx; line-height: 1.65; overflow-wrap: anywhere; }
.sun-icon { position: relative; flex-shrink: 0; width: 60rpx; height: 60rpx; color: #8c826f; }
.sun-core { position: absolute; top: 18rpx; left: 18rpx; width: 24rpx; height: 24rpx; box-sizing: border-box; border: 2rpx solid currentColor; border-radius: 50%; }
.sun-ray { position: absolute; top: 0; left: 29rpx; height: 60rpx; width: 2rpx; }
.sun-ray::before, .sun-ray::after { position: absolute; left: 0; width: 2rpx; height: 10rpx; border-radius: 2rpx; background: currentColor; content: ''; }
.sun-ray::before { top: 0; }.sun-ray::after { bottom: 0; }
.ray-2 { transform: rotate(45deg); }.ray-3 { transform: rotate(90deg); }.ray-4 { transform: rotate(135deg); }
.is-entering .welcome-copy { animation: welcome-in .52s cubic-bezier(.22, 1, .36, 1) both; }
.is-entering .studio-photo { animation: welcome-in .6s .08s cubic-bezier(.22, 1, .36, 1) both; }
.is-entering .daily-reminder { animation: welcome-in .5s .16s cubic-bezier(.22, 1, .36, 1) both; }
@keyframes welcome-in { from { opacity: 0; transform: translateY(16rpx); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.is-entering .welcome-copy, .is-entering .studio-photo, .is-entering .daily-reminder { animation: none; }
.hero-image { transition: none; }
}
</style>