feat: 支持新版的首页 UI
This commit is contained in:
@@ -1,126 +1,114 @@
|
||||
<template>
|
||||
<view class="brand-banner">
|
||||
<!-- Background image layer with blur -->
|
||||
<image
|
||||
class="banner-bg"
|
||||
:src="bannerImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<!-- Dark overlay for readability -->
|
||||
<view class="banner-overlay" />
|
||||
|
||||
<!-- Centered content -->
|
||||
<view class="banner-content">
|
||||
<!-- Circular logo -->
|
||||
<view class="logo-circle">
|
||||
<image
|
||||
v-if="logoImage"
|
||||
class="logo-img"
|
||||
:src="logoImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="logo-placeholder">
|
||||
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
|
||||
<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>
|
||||
|
||||
<!-- Studio name -->
|
||||
<text class="studio-name">{{ studioName }}</text>
|
||||
</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 } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
import { getHomeGreeting, getHomeMoment, type HomeVisitorState } from '../utils/home'
|
||||
|
||||
const props = defineProps<{
|
||||
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 })
|
||||
|
||||
const fallbackBannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
|
||||
const fallbackLogoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.jpg'
|
||||
|
||||
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
|
||||
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
|
||||
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
|
||||
// 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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 580rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #E1F4FA 0%, $primary-color 50%, $primary-dark 100%);
|
||||
}
|
||||
|
||||
.banner-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: blur(2px);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba($primary-dark, 0.25);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 120rpx;
|
||||
}
|
||||
|
||||
.logo-circle {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
font-size: 64rpx;
|
||||
font-weight: 800;
|
||||
color: #333;
|
||||
letter-spacing: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.studio-name {
|
||||
margin-top: 28rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: 6rpx;
|
||||
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.3);
|
||||
.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>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<view class="quick-entry">
|
||||
<view v-if="userStore.loggedIn && !userStore.membershipsLoaded && !userStore.membershipsError" class="entry-pill pill-loading" aria-label="正在加载会员信息">
|
||||
<view class="loading-label" /><view class="loading-action" />
|
||||
</view>
|
||||
<view v-else-if="userStore.loggedIn && !userStore.membershipsLoaded && userStore.membershipsError" class="entry-pill pill-expired" role="button" @tap="retryMemberships">
|
||||
<text class="pill-label">会员信息暂未加载</text>
|
||||
<view class="pill-action"><text class="pill-action-text">{{ userStore.membershipsLoading ? '加载中' : '重新加载' }}</text></view>
|
||||
</view>
|
||||
<!-- ① Not logged in -->
|
||||
<view v-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" @tap="handlePortrait">
|
||||
<view v-else-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" role="button" hover-class="entry-pressed" @tap="handlePortrait">
|
||||
<text class="pill-label">3 分钟了解自己的身体状态</text>
|
||||
<view class="pill-action action-login">
|
||||
<text class="pill-action-text">开始评估</text>
|
||||
@@ -9,8 +16,8 @@
|
||||
</view>
|
||||
|
||||
<!-- Has valid active card -->
|
||||
<template v-else-if="userStore.hasValidMembership">
|
||||
<view class="entry-pill pill-active" @tap="handleBooking">
|
||||
<template v-else-if="activeMemberships.length">
|
||||
<view class="entry-pill pill-active" role="button" hover-class="entry-pressed" @tap="handleBooking">
|
||||
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
||||
<view class="pill-action action-book">
|
||||
<text class="pill-action-text">预约课程</text>
|
||||
@@ -24,10 +31,17 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- ④ Has memberships but none active → buy card -->
|
||||
<view v-else-if="hasScheduledMembership" class="entry-pill pill-expired" role="button" hover-class="entry-pressed" @tap="handleMemberships">
|
||||
<text class="pill-label">会员卡尚未生效</text>
|
||||
<view class="pill-action"><text class="pill-action-text">查看会员卡</text></view>
|
||||
</view>
|
||||
|
||||
<!-- Has memberships but none available → renew -->
|
||||
<view
|
||||
v-else
|
||||
class="entry-pill pill-expired"
|
||||
role="button"
|
||||
hover-class="entry-pressed"
|
||||
@tap="handleRenew"
|
||||
>
|
||||
<text class="pill-label">暂无可用会员卡</text>
|
||||
@@ -39,47 +53,49 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { getMembershipEffectiveState, getMembershipRenewalHint, MembershipStatus } from '@mp-pilates/shared'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { getErrorMessage } from '../utils/auth'
|
||||
import { getHomeMembershipDays } from '../utils/home'
|
||||
|
||||
const props = defineProps<{ now: number }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scroll-to-card-shop'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const activeMemberships = computed(() => userStore.memberships.filter(membership =>
|
||||
getMembershipEffectiveState(membership, new Date(props.now)).status === MembershipStatus.ACTIVE
|
||||
&& (membership.remainingTimes === null || membership.remainingTimes > 0),
|
||||
))
|
||||
const renewalHint = computed(() => getMembershipRenewalHint(userStore.memberships, new Date(props.now)))
|
||||
const hasScheduledMembership = computed(() => userStore.memberships.some(membership =>
|
||||
getMembershipEffectiveState(membership, new Date(props.now)).status === MembershipStatus.SCHEDULED,
|
||||
))
|
||||
|
||||
async function handleLogin() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.loginWithSetup()
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
function retryMemberships() {
|
||||
if (!userStore.membershipsLoading) void userStore.fetchMemberships()
|
||||
}
|
||||
|
||||
function handlePortrait() {
|
||||
uni.navigateTo({ url: '/pages/portrait/index' })
|
||||
}
|
||||
|
||||
function handleTrialEntry() {
|
||||
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
||||
}
|
||||
|
||||
function handleBooking() {
|
||||
uni.switchTab({ url: '/pages/booking/index' })
|
||||
}
|
||||
|
||||
function handleMemberships() {
|
||||
uni.navigateTo({ url: '/pages/profile/membership' })
|
||||
}
|
||||
|
||||
function scrollToCardShop() {
|
||||
emit('scroll-to-card-shop')
|
||||
}
|
||||
|
||||
function handleRenew() {
|
||||
const cardTypeId = userStore.renewalHint?.cardTypeId
|
||||
const cardTypeId = renewalHint.value?.cardTypeId
|
||||
if (cardTypeId) {
|
||||
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
|
||||
return
|
||||
@@ -88,22 +104,20 @@ function handleRenew() {
|
||||
}
|
||||
|
||||
const activeMembershipLabel = computed(() => {
|
||||
const active = userStore.activeMemberships
|
||||
const active = activeMemberships.value
|
||||
if (!active.length) return ''
|
||||
const m = active[0]
|
||||
const cardName = m.cardType.name
|
||||
if (m.remainingTimes !== null) {
|
||||
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
||||
}
|
||||
const expire = new Date(m.expireDate)
|
||||
const today = new Date()
|
||||
const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000)
|
||||
const daysLeft = getHomeMembershipDays(m.expireDate, new Date(props.now))
|
||||
return `${cardName} · 剩余 ${daysLeft} 天`
|
||||
})
|
||||
|
||||
const renewStripText = computed(() => {
|
||||
const hint = userStore.renewalHint
|
||||
if (!hint || !userStore.hasValidMembership) return ''
|
||||
const hint = renewalHint.value
|
||||
if (!hint || !activeMemberships.value.length) return ''
|
||||
if (hint.kind === 'times_low') {
|
||||
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
|
||||
}
|
||||
@@ -119,14 +133,21 @@ const renewStripText = computed(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.quick-entry { margin: 24rpx 32rpx 0; }
|
||||
.entry-pill { display: flex; align-items: center; min-height: 120rpx; box-sizing: border-box; padding: 24rpx; gap: 20rpx; border-radius: 28rpx; background: #f1e8e1; }
|
||||
.entry-pill { display: flex; align-items: center; min-height: 136rpx; box-sizing: border-box; padding: 24rpx 28rpx; gap: 20rpx; border-radius: 30rpx; background: #f1ece5; transition: opacity .18s ease, transform .18s ease; }
|
||||
.entry-pressed { opacity: .82; transform: scale(.99); }
|
||||
.pill-active { background: #edf2ec; }
|
||||
.pill-expired { background: #f0ece7; }
|
||||
.pill-label { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.6; color: #6f5c50; overflow-wrap: anywhere; }
|
||||
.pill-label { flex: 1; min-width: 0; font-size: 29rpx; line-height: 1.6; color: #403c36; overflow-wrap: anywhere; }
|
||||
.pill-label-active { color: #526b5d; }
|
||||
.pill-action { flex-shrink: 0; min-height: 68rpx; padding: 0 24rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
|
||||
.pill-action-text { font-size: 24rpx; font-weight: 400; color: #fff; white-space: nowrap; }
|
||||
.pill-action { flex-shrink: 0; min-height: 80rpx; padding: 0 28rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
|
||||
.pill-action-text { font-size: 27rpx; font-weight: 400; color: #fff; white-space: nowrap; }
|
||||
.renew-strip { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 18rpx 12rpx 0; }
|
||||
.renew-strip-text { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
|
||||
.renew-strip-arrow { font-size: 28rpx; color: #8b817b; }
|
||||
.pill-loading { justify-content: space-between; background: #edf2ec; }
|
||||
.loading-label { width: 55%; height: 28rpx; border-radius: 8rpx; background: #dce3d9; }
|
||||
.loading-action { width: 180rpx; height: 80rpx; border-radius: 999rpx; background: #dce3d9; }
|
||||
.loading-label, .loading-action { animation: loading-breathe 1.4s ease-in-out infinite alternate; }
|
||||
@keyframes loading-breathe { to { opacity: .45; } }
|
||||
@media (prefers-reduced-motion: reduce) { .entry-pill { transition: none; }.loading-label, .loading-action { animation: none; } }
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<view class="case-card">
|
||||
<view class="heading" @tap="open"><view class="copy"><text class="name">{{ item.displayName }} · 练习记录</text><text class="title">{{ item.title }}</text></view><view class="lessons"><text class="number">{{ item.lessonCount }}</text><text>节练习</text></view></view>
|
||||
<TeachingCaseGallery :frames="item.frames" :lesson-count="item.lessonCount" @refresh="$emit('refresh')" />
|
||||
<view class="case-card" :class="{ 'is-home': home }">
|
||||
<view class="heading" @tap="open"><view class="copy"><text class="name">{{ item.displayName }} · 练习记录</text><text class="title">{{ item.title }}</text></view><view class="lessons"><text class="number lesson-count">{{ item.lessonCount }}</text><text class="lesson-unit">节练习</text></view></view>
|
||||
<TeachingCaseGallery :frames="item.frames" :lesson-count="item.lessonCount" :compact="home" @refresh="$emit('refresh')" />
|
||||
<text class="achievement">{{ item.achievement }}</text>
|
||||
<button class="link" @tap="open">看这段改变的过程 <text>↗</text></button>
|
||||
<button class="link" @tap="open">看这段改变的过程 <text class="link-arrow">↗</text></button>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { TeachingCasePublic } from '@mp-pilates/shared'
|
||||
import TeachingCaseGallery from './TeachingCaseGallery.vue'
|
||||
const props = defineProps<{ item: TeachingCasePublic }>()
|
||||
const props = defineProps<{ item: TeachingCasePublic; home?: boolean }>()
|
||||
defineEmits<{ refresh: [] }>()
|
||||
function open() { uni.navigateTo({ url: `/pages/home/teaching-case?id=${props.item.id}` }) }
|
||||
</script>
|
||||
<style scoped>
|
||||
.case-card{padding:28rpx 0;border-bottom:1rpx solid #e4ded4;}.heading{display:flex;justify-content:space-between;gap:20rpx;align-items:center;margin-bottom:24rpx;}.copy{min-width:0;}.name{display:block;font-size:22rpx;color:#8b8174;margin-bottom:12rpx;}.title{font-size:34rpx;line-height:1.5;color:#4b4940;font-family:'Songti SC','STSong',serif;}.lessons{flex-shrink:0;text-align:center;color:#64705c;font-size:20rpx;}.lessons text{display:block;}.lessons .number{font-family:'Baskerville',serif;font-size:58rpx;line-height:1.1;}.achievement{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:22rpx;font-size:25rpx;line-height:1.8;color:#746d61;white-space:pre-wrap;}.link{padding:0;margin:10rpx 0 0;text-align:left;font-size:25rpx;line-height:80rpx;color:#596b50;background:transparent;}.link text{float:right;}.link::after{border:0;}
|
||||
.case-card{padding:28rpx 0;border-bottom:1rpx solid #e4ded4;}.heading{display:flex;justify-content:space-between;gap:20rpx;align-items:center;margin-bottom:24rpx;}.copy{min-width:0;}.name{display:block;font-size:22rpx;color:#8b8174;margin-bottom:12rpx;}.title{font-size:34rpx;line-height:1.5;color:#4b4940;font-family:'Songti SC','STSong',serif;}.lessons{flex-shrink:0;text-align:center;color:#64705c;font-size:20rpx;}.lesson-count,.lesson-unit{display:block;}.lessons .number{font-family:'Baskerville',serif;font-size:58rpx;line-height:1.1;}.achievement{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:22rpx;font-size:25rpx;line-height:1.8;color:#746d61;white-space:pre-wrap;}.link{padding:0;margin:10rpx 0 0;text-align:left;font-size:25rpx;line-height:80rpx;color:#596b50;background:transparent;}.link-arrow{float:right;}.link::after{border:0;}
|
||||
.is-home{padding:26rpx 20rpx 10rpx;margin-bottom:24rpx;border:0;border-radius:30rpx;background:#fff;}.is-home .heading{padding:0 8rpx;margin-bottom:24rpx;}.is-home .name{font-size:23rpx;color:#756a60;}.is-home .title{font-family:inherit;font-size:33rpx;color:#403c36;}.is-home .lessons{color:#8b817b;font-size:20rpx;}.is-home .lessons .number{font-family:inherit;font-size:43rpx;color:#292a27;margin-bottom:8rpx;}.is-home .achievement{padding:0 8rpx;}.is-home .link{margin:0 8rpx;font-size:23rpx;line-height:70rpx;}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<template>
|
||||
<view class="gallery">
|
||||
<view class="gallery" :class="{ compact }">
|
||||
<swiper class="slides" :current="current" :autoplay="false" @change="current = $event.detail.current">
|
||||
<swiper-item v-for="(frame, index) in frames" :key="index">
|
||||
<view class="frame" :class="{ pair: frame.kind === 'pair' }">
|
||||
<view class="photo" @tap="preview(frame.beforeUrl)">
|
||||
<image v-if="frame.beforeUrl" :src="frame.beforeUrl" mode="aspectFit" lazy-load @error="failed = true" />
|
||||
<image class="gallery-image" v-if="frame.beforeUrl" :src="frame.beforeUrl" mode="aspectFit" lazy-load @error="failed = true" />
|
||||
<text v-if="frame.kind === 'pair'" class="badge">练习前</text>
|
||||
</view>
|
||||
<view v-if="frame.kind === 'pair'" class="photo" @tap="preview(frame.afterUrl)">
|
||||
<image v-if="frame.afterUrl" :src="frame.afterUrl" mode="aspectFit" lazy-load @error="failed = true" />
|
||||
<image class="gallery-image" v-if="frame.afterUrl" :src="frame.afterUrl" mode="aspectFit" lazy-load @error="failed = true" />
|
||||
<text class="badge after">{{ lessonCount }} 节后</text>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="gallery-footer">
|
||||
<view v-if="!compact || frames.length > 1 || frames[current]?.caption" class="gallery-footer">
|
||||
<text class="caption">{{ frames[current]?.caption || '每一次练习,都留下改变的痕迹' }}</text>
|
||||
<view class="pager"><button v-if="frames.length > 1" aria-label="上一组" @tap="move(-1)">‹</button><text>{{ current + 1 }} / {{ frames.length }}</text><button v-if="frames.length > 1" aria-label="下一组" @tap="move(1)">›</button></view>
|
||||
<view class="pager"><button class="pager-button" v-if="frames.length > 1" aria-label="上一组" @tap="move(-1)">‹</button><text>{{ current + 1 }} / {{ frames.length }}</text><button class="pager-button" v-if="frames.length > 1" aria-label="下一组" @tap="move(1)">›</button></view>
|
||||
</view>
|
||||
<button v-if="failed" class="retry" @tap="$emit('refresh')">图片未能加载,点击刷新</button>
|
||||
</view>
|
||||
@@ -24,7 +24,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { TeachingCaseFrame } from '@mp-pilates/shared'
|
||||
const props = defineProps<{ frames: TeachingCaseFrame[]; lessonCount: number }>()
|
||||
const props = defineProps<{ frames: TeachingCaseFrame[]; lessonCount: number; compact?: boolean }>()
|
||||
defineEmits<{ refresh: [] }>()
|
||||
const current = ref(0), failed = ref(false)
|
||||
watch(() => props.frames, () => { current.value = 0; failed.value = false })
|
||||
@@ -36,5 +36,6 @@ function preview(url?: string) {
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.gallery{overflow:hidden;background:#f0eee8;border-radius:18rpx;}.slides{height:480rpx;}.frame{height:100%;display:flex;}.photo{position:relative;flex:1;min-width:0;height:100%;}.pair .photo+.photo{border-left:4rpx solid #fbf9f6;}.photo image{width:100%;height:100%;}.badge{position:absolute;left:14rpx;bottom:16rpx;padding:8rpx 16rpx;border-radius:8rpx;background:rgba(255,253,249,.93);color:#62584d;font-size:22rpx;}.after{background:#53624f;color:#fff;}.gallery-footer{padding:12rpx 18rpx;display:flex;align-items:center;gap:12rpx;min-height:64rpx;}.caption{flex:1;font-size:22rpx;color:#817566;line-height:1.6;}.pager{display:flex;align-items:center;flex-shrink:0;color:#716353;font-size:22rpx;}.pager button{margin:0;padding:0;width:64rpx;line-height:68rpx;background:transparent;color:#53624f;font-size:36rpx;}.pager button::after,.retry::after{border:0;}.retry{background:#eee5da;color:#765c44;font-size:24rpx;line-height:80rpx;}
|
||||
.gallery{overflow:hidden;background:#f0eee8;border-radius:18rpx;}.slides{height:480rpx;}.frame{height:100%;display:flex;}.photo{position:relative;flex:1;min-width:0;height:100%;}.pair .photo+.photo{border-left:4rpx solid #fbf9f6;}.gallery-image{width:100%;height:100%;}.badge{position:absolute;left:14rpx;bottom:16rpx;padding:8rpx 16rpx;border-radius:8rpx;background:rgba(255,253,249,.93);color:#62584d;font-size:22rpx;}.after{background:#53624f;color:#fff;}.gallery-footer{padding:12rpx 18rpx;display:flex;align-items:center;gap:12rpx;min-height:64rpx;}.caption{flex:1;font-size:22rpx;color:#817566;line-height:1.6;}.pager{display:flex;align-items:center;flex-shrink:0;color:#716353;font-size:22rpx;}.pager-button{margin:0;padding:0;width:64rpx;line-height:68rpx;background:transparent;color:#53624f;font-size:36rpx;}.pager-button::after,.retry::after{border:0;}.retry{background:#eee5da;color:#765c44;font-size:24rpx;line-height:80rpx;}
|
||||
.compact .slides{height:440rpx;}.compact .photo+.photo{border-left:10rpx solid #fff;}.compact .gallery-footer{background:#fff;}.compact .badge{font-size:19rpx;padding:6rpx 12rpx;}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view v-if="items.length" class="section">
|
||||
<view class="section-head"><view><text class="eyebrow">练习,有迹可循</text><text class="title">看见真实的改变</text></view><button @tap="openAll">全部案例 ›</button></view>
|
||||
<TeachingCaseCard v-for="item in items" :key="item.id" :item="item" @refresh="$emit('refresh')" />
|
||||
<view class="section-head"><view><text class="eyebrow">练习,有迹可循</text><text class="title">看见真实的改变</text></view><button class="all-cases" @tap="openAll">全部案例 ›</button></view>
|
||||
<TeachingCaseCard v-for="item in items.slice(0, 1)" :key="item.id" :item="item" home @refresh="$emit('refresh')" />
|
||||
<text class="footnote">经学员授权分享 · 个体经历,实际变化因人而异</text>
|
||||
</view>
|
||||
</template>
|
||||
@@ -13,5 +13,5 @@ defineEmits<{ refresh: [] }>()
|
||||
function openAll() { uni.navigateTo({ url: '/pages/home/teaching-cases' }) }
|
||||
</script>
|
||||
<style scoped>
|
||||
.section{margin:28rpx 32rpx 40rpx;}.section-head{display:flex;justify-content:space-between;align-items:center;gap:12rpx;}.eyebrow{display:block;font-size:21rpx;letter-spacing:3rpx;color:#8a876e;margin-bottom:13rpx;}.title{font-family:'Songti SC','STSong',serif;font-size:40rpx;line-height:1.5;color:#494c40;}.section-head button{flex-shrink:0;margin:0;padding:0 10rpx;font-size:23rpx;color:#738064;line-height:88rpx;background:transparent;}.section-head button::after{border:0;}.footnote{display:block;margin-top:24rpx;font-size:21rpx;color:#948b7c;line-height:1.7;}
|
||||
.section{margin:36rpx 32rpx 40rpx;}.section-head{display:flex;justify-content:space-between;align-items:center;gap:12rpx;margin-bottom:28rpx;}.eyebrow{display:block;font-size:23rpx;letter-spacing:1rpx;color:#8b817b;margin-bottom:10rpx;}.title{font-family:'Songti SC','STSong',serif;font-size:43rpx;line-height:1.5;color:#403c36;}.all-cases{flex-shrink:0;align-self:flex-end;margin:0;padding:0;font-size:24rpx;color:#8b817b;line-height:68rpx;background:transparent;}.all-cases::after{border:0;}.footnote{display:block;margin-top:24rpx;font-size:21rpx;color:#948b7c;line-height:1.7;}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view v-if="bookingStore.upcomingBookings.length" class="upcoming-section">
|
||||
<view v-if="displayedBookings.length || bookingStore.upcomingError" class="upcoming-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">即将上课</text>
|
||||
<text class="section-more" @tap="goToBookings">全部预约 ›</text>
|
||||
@@ -9,13 +9,14 @@
|
||||
v-for="booking in displayedBookings"
|
||||
:key="booking.id"
|
||||
class="booking-card"
|
||||
role="button"
|
||||
hover-class="booking-pressed"
|
||||
@tap="goToBookingDetail(booking.id)"
|
||||
>
|
||||
<!-- Date column -->
|
||||
<view class="date-col">
|
||||
<text class="date-day">{{ getDayNumber(booking.timeSlot.date) }}</text>
|
||||
<text class="date-month">{{ getMonthLabel(booking.timeSlot.date) }}</text>
|
||||
<text class="date-weekday">{{ getWeekdayLabel(booking.timeSlot.date) }}</text>
|
||||
<text class="date-day">{{ getHomeBookingDate(booking.timeSlot.date).day }}</text>
|
||||
<text class="date-caption">{{ getHomeBookingDate(booking.timeSlot.date).caption }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Divider -->
|
||||
@@ -40,31 +41,27 @@
|
||||
<!-- Right arrow -->
|
||||
<text class="booking-arrow">›</text>
|
||||
</view>
|
||||
<view v-if="bookingStore.upcomingError" class="booking-error">
|
||||
<text>{{ displayedBookings.length ? '预约更新暂未完成' : '预约暂未加载,请稍后重试' }}</text>
|
||||
<button class="retry-button" :disabled="bookingStore.upcomingLoading" @tap="bookingStore.fetchUpcomingBookings()">{{ bookingStore.upcomingLoading ? '加载中' : '重新加载' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useBookingStore } from '../stores/booking'
|
||||
import { getWeekdayLabel } from '../utils/format'
|
||||
import { getHomeBookingDate, getHomeUpcomingBookings } from '../utils/home'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{ now: number }>()
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// Show at most 2 upcoming bookings
|
||||
const displayedBookings = computed(() =>
|
||||
bookingStore.upcomingBookings.slice(0, 2),
|
||||
getHomeUpcomingBookings(bookingStore.upcomingBookings, props.now),
|
||||
)
|
||||
|
||||
function getDayNumber(dateStr: string): string {
|
||||
return new Date(dateStr).getDate().toString()
|
||||
}
|
||||
|
||||
function getMonthLabel(dateStr: string): string {
|
||||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
||||
return months[new Date(dateStr).getMonth()]
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string): string {
|
||||
// timeStr might be "HH:mm:ss" or "HH:mm"
|
||||
return timeStr.slice(0, 5)
|
||||
@@ -107,17 +104,18 @@ function goToBookingDetail(id: string) {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upcoming-section { margin: 36rpx 32rpx 0; }
|
||||
.upcoming-section { margin: 36rpx 32rpx 0; padding-bottom: 32rpx; border-bottom: 1rpx solid #e3ddd5; }
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||
.section-title { font-size: 38rpx; font-weight: 500; color: #403c36; }
|
||||
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
|
||||
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 26rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
||||
.date-col { display: flex; flex-direction: column; align-items: center; gap: 4rpx; width: 72rpx; flex-shrink: 0; }
|
||||
.date-day { font-size: 44rpx; font-weight: 400; line-height: 1.1; color: #617d73; }
|
||||
.date-month, .date-weekday { font-size: 20rpx; color: #8b817b; }
|
||||
.booking-divider { width: 1rpx; height: 80rpx; background: #eee8e3; flex-shrink: 0; }
|
||||
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 30rpx 26rpx; border-radius: 30rpx; background: #fff; transition: opacity .18s ease, transform .18s ease; }
|
||||
.booking-pressed { opacity: .8; transform: scale(.99); }
|
||||
.date-col { display: flex; flex-direction: column; align-items: center; gap: 10rpx; width: 148rpx; flex-shrink: 0; }
|
||||
.date-day { font-size: 68rpx; font-weight: 400; line-height: 1.1; color: #292a27; font-variant-numeric: tabular-nums; }
|
||||
.date-caption { font-size: 24rpx; color: #8b817b; white-space: nowrap; }
|
||||
.booking-divider { width: 1rpx; height: 124rpx; background: #eee8e3; flex-shrink: 0; }
|
||||
.details-col { flex: 1; min-width: 0; }
|
||||
.card-name { display: block; font-size: 26rpx; color: #514943; font-weight: 400; line-height: 1.5; margin-bottom: 8rpx; overflow-wrap: anywhere; }
|
||||
.card-name { display: block; font-size: 29rpx; color: #403c36; font-weight: 500; line-height: 1.5; margin-bottom: 8rpx; overflow-wrap: anywhere; }
|
||||
.time-row { margin-bottom: 8rpx; }
|
||||
.time-text { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
|
||||
.status-row { display: flex; align-items: center; gap: 8rpx; }
|
||||
@@ -128,4 +126,7 @@ function goToBookingDetail(id: string) {
|
||||
.dot--cancelled { background: #b69589; }
|
||||
.text--cancelled { color: #9c7a6e; }
|
||||
.booking-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
|
||||
.booking-error { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 20rpx 0; font-size: 23rpx; line-height: 1.6; color: #8b817b; }
|
||||
.retry-button { margin: 0; padding: 8rpx 12rpx; flex-shrink: 0; background: transparent; font-size: 23rpx; line-height: 1.6; color: #617d73; }.retry-button::after { border: 0; }
|
||||
@media (prefers-reduced-motion: reduce) { .booking-card { transition: none; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user