perf: 优化我的课程 UI

This commit is contained in:
richarjiang
2026-09-08 17:31:26 +08:00
parent f54a12efbd
commit 8e2db3a74a
4 changed files with 941 additions and 526 deletions

View File

@@ -0,0 +1,78 @@
<template>
<view class="month-divider">
<!-- 左侧细线 -->
<view class="month-divider__line" />
<!-- 月份主体 -->
<view class="month-divider__body">
<!-- 月份装饰一个小圆点 + 月份名 -->
<view class="month-divider__dot" />
<text class="month-divider__label">{{ label }}</text>
<view class="month-divider__dot" />
</view>
<!-- 右侧细线 -->
<view class="month-divider__line" />
<!-- 副信息节气或描述 -->
<text v-if="hint" class="month-divider__hint">{{ hint }}</text>
</view>
</template>
<script setup lang="ts">
defineProps<{
/** 主标签,例如 "十月" */
label: string
/** 副信息,例如 "共 3 节" */
hint?: string
}>()
</script>
<style lang="scss" scoped>
.month-divider {
display: flex;
align-items: center;
gap: 16rpx;
margin: 48rpx 32rpx 24rpx;
&__line {
flex: 1;
height: 1rpx;
background: linear-gradient(
to right,
transparent 0%,
rgba(155, 138, 117, 0.25) 30%,
rgba(155, 138, 117, 0.45) 100%
);
}
&__body {
display: flex;
align-items: center;
gap: 14rpx;
padding: 0 4rpx;
}
&__dot {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: #b09a83;
}
&__label {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 28rpx;
font-weight: 400;
color: #6f645a;
letter-spacing: 8rpx;
}
&__hint {
font-size: 22rpx;
color: #a89d92;
letter-spacing: 1rpx;
font-variant-numeric: tabular-nums;
}
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<view class="hero" :class="`hero--${tone}`" @tap="handleTap">
<!-- 背景纹理层 -->
<view class="hero__grain" />
<!-- 装饰右上角的弧线暗示月相 -->
<view class="hero__moon" />
<view class="hero__main">
<!-- 左侧大日期块 -->
<view class="hero__date">
<text class="hero__day">{{ dayNumber }}</text>
<text class="hero__month">{{ monthLabel }}</text>
<text class="hero__weekday">{{ weekdayLabel }}</text>
</view>
<!-- 分隔虚线 -->
<view class="hero__divider">
<view v-for="i in 6" :key="i" class="hero__divider-dot" />
</view>
<!-- 右侧课程信息 -->
<view class="hero__info">
<view class="hero__time-row">
<text class="hero__time">{{ startTime }}</text>
<text class="hero__time-end"> {{ endTime }}</text>
</view>
<text class="hero__membership">{{ cardName }}</text>
<view class="hero__status">
<view class="hero__dot" />
<text class="hero__status-text">{{ statusLabel }}</text>
<text v-if="countdownText" class="hero__countdown">· {{ countdownText }}</text>
</view>
</view>
</view>
<!-- 底部诗意一句 -->
<view class="hero__footer">
<text class="hero__poem">{{ poem }}</text>
<view class="hero__cta">
<text class="hero__cta-text">查看详情</text>
<text class="hero__cta-arrow"></text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import {
bookingStatusLabel,
bookingStatusBannerClass,
} from '../utils/booking-helpers'
const props = defineProps<{
booking: BookingWithDetails
}>()
const emit = defineEmits<{
tap: [booking: BookingWithDetails]
}>()
const months = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function parseDate(dateStr: string): Date {
const normalized = dateStr.slice(0, 10)
const [y, m, d] = normalized.split('-').map(Number)
return new Date(y, m - 1, d)
}
const date = computed(() => parseDate(props.booking.timeSlot.date))
const dayNumber = computed(() => String(date.value.getDate()).padStart(2, '0'))
const monthLabel = computed(() => `${date.value.getMonth() + 1}`)
const weekdayLabel = computed(() => weekdays[date.value.getDay()])
const startTime = computed(() => props.booking.timeSlot.startTime.slice(0, 5))
const endTime = computed(() => props.booking.timeSlot.endTime.slice(0, 5))
const cardName = computed(() => props.booking.membership?.cardType?.name || '会员卡')
const statusLabel = computed(() => bookingStatusLabel(props.booking.status))
const tone = computed(() => bookingStatusBannerClass(props.booking.status))
const today = new Date()
const todayStr = computed(() => {
const d = today
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const tomorrow = new Date(today.getTime() + 86400000)
const tomorrowStr = computed(() => {
const d = tomorrow
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const dateKey = computed(() => {
const d = date.value
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const relativeLabel = computed(() => {
if (dateKey.value === todayStr.value) return '今天'
if (dateKey.value === tomorrowStr.value) return '明天'
return `${months[date.value.getMonth()]}${date.value.getDate()}`
})
const countdownText = computed(() => {
if (props.booking.status !== BookingStatus.CONFIRMED) return ''
if (dateKey.value !== todayStr.value && dateKey.value !== tomorrowStr.value) return ''
const target = new Date(`${dateKey.value}T${props.booking.timeSlot.startTime}:00`).getTime()
const diff = target - Date.now()
if (diff <= 0) return '即将开始'
const hours = Math.floor(diff / 3_600_000)
const mins = Math.floor((diff % 3_600_000) / 60_000)
if (dateKey.value === todayStr.value) {
if (hours <= 0) return `${mins} 分钟后`
return `${hours} 小时 ${mins} 分后`
}
return `${hours} 小时 ${mins}`
})
const poem = computed(() => {
const poems: Record<string, string> = {
today: '让呼吸慢一些,让脊柱回到中央。',
tomorrow: '明天见,记得早些休息。',
upcoming: '你的身体,会记得每一次到达。',
pending: '待确认中,我们会为你预留这朵花。',
}
if (dateKey.value === todayStr.value) return poems.today
if (dateKey.value === tomorrowStr.value) return poems.tomorrow
if (props.booking.status === BookingStatus.PENDING_CONFIRMATION) return poems.pending
return poems.upcoming
})
function handleTap() {
emit('tap', props.booking)
}
</script>
<style lang="scss" scoped>
.hero {
position: relative;
margin: 24rpx 32rpx 0;
padding: 36rpx 32rpx 28rpx;
border-radius: 36rpx;
overflow: hidden;
background: linear-gradient(140deg, #efe5d6 0%, #e6d8c4 100%);
color: #3a322b;
box-shadow:
0 1rpx 0 rgba(122, 99, 84, 0.04),
0 12rpx 32rpx rgba(122, 99, 84, 0.06);
&--pending {
background: linear-gradient(140deg, #ece1cd 0%, #e2d2b8 100%);
}
&--confirmed {
background: linear-gradient(140deg, #e2ebe2 0%, #d4e0d3 100%);
}
&--completed {
background: linear-gradient(140deg, #efe5d6 0%, #e6d8c4 100%);
}
&--cancelled {
background: linear-gradient(140deg, #ede4dc 0%, #e3d6c9 100%);
opacity: 0.85;
}
&--noshow {
background: linear-gradient(140deg, #ede0dc 0%, #e3cfc7 100%);
}
&__grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.4;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.45 0 0 0 0 0.36 0 0 0 0 0.27 0 0 0 0.18 0'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>");
mix-blend-mode: multiply;
}
&__moon {
position: absolute;
top: -80rpx;
right: -60rpx;
width: 220rpx;
height: 220rpx;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, rgba(255, 248, 235, 0.55) 0%, rgba(255, 248, 235, 0) 70%);
pointer-events: none;
}
&__main {
position: relative;
display: flex;
align-items: stretch;
gap: 24rpx;
}
&__date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
width: 132rpx;
flex-shrink: 0;
padding: 8rpx 0;
border-radius: 20rpx;
background: rgba(255, 251, 244, 0.5);
backdrop-filter: blur(8rpx);
}
&__day {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 72rpx;
line-height: 1;
font-weight: 500;
color: #3a322b;
font-variant-numeric: tabular-nums;
}
&__month {
margin-top: 8rpx;
font-size: 22rpx;
color: #6f645a;
letter-spacing: 2rpx;
}
&__weekday {
font-size: 20rpx;
color: #8b7d70;
letter-spacing: 1rpx;
}
&__divider {
display: flex;
flex-direction: column;
justify-content: center;
gap: 6rpx;
flex-shrink: 0;
width: 2rpx;
padding: 8rpx 0;
}
&__divider-dot {
width: 2rpx;
height: 8rpx;
background: #b09a83;
border-radius: 1rpx;
opacity: 0.5;
}
&__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8rpx;
padding: 4rpx 0;
}
&__time-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
&__time {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 50rpx;
line-height: 1;
font-weight: 500;
color: #2a2520;
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
}
&__time-end {
font-size: 24rpx;
color: #6f645a;
font-variant-numeric: tabular-nums;
}
&__membership {
font-size: 24rpx;
color: #6f645a;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__status {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 4rpx;
}
&__dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #6e8b7d;
box-shadow: 0 0 0 4rpx rgba(110, 139, 125, 0.18);
}
&__status-text {
font-size: 22rpx;
color: #6e8b7d;
letter-spacing: 1rpx;
}
&__countdown {
font-size: 22rpx;
color: #8b7d70;
font-variant-numeric: tabular-nums;
}
&__footer {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 28rpx;
padding-top: 20rpx;
border-top: 1rpx dashed rgba(122, 99, 84, 0.22);
}
&__poem {
flex: 1;
font-size: 22rpx;
color: #6f645a;
letter-spacing: 2rpx;
font-style: italic;
line-height: 1.5;
}
&__cta {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 0;
}
&__cta-text {
font-size: 22rpx;
color: #8b7d70;
letter-spacing: 2rpx;
}
&__cta-arrow {
font-size: 22rpx;
color: #8b7d70;
transform: translateY(-1rpx);
transition: transform 0.3s ease;
}
}
.hero:active {
transform: scale(0.99);
transition: transform 0.15s ease;
.hero__cta-arrow {
transform: translate(4rpx, -1rpx);
}
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<view
class="session"
:class="[
`session--${tone}`,
{ 'session--muted': muted, 'session--history': history },
]"
@tap="handleTap"
>
<!-- 左侧日期块 -->
<view class="session__date">
<text class="session__day">{{ dayNumber }}</text>
<text class="session__weekday">{{ weekdayLabel }}</text>
</view>
<!-- 中央分隔虚线 -->
<view class="session__rail">
<view class="session__dot" />
<view class="session__line" />
</view>
<!-- 主体信息 -->
<view class="session__body">
<view class="session__top">
<text class="session__time">{{ startTime }}</text>
<text class="session__time-end"> {{ endTime }}</text>
</view>
<text class="session__membership">{{ cardName }}</text>
<view class="session__bottom">
<view class="session__status">
<view class="session__status-dot" />
<text class="session__status-text">{{ statusLabel }}</text>
</view>
<text v-if="!history" class="session__cancel" @tap.stop="handleCancel">取消预约</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import {
bookingStatusLabel,
bookingStatusStripeClass,
} from '../utils/booking-helpers'
const props = defineProps<{
booking: BookingWithDetails
/** 历史记录模式(更紧凑,隐藏取消按钮) */
history?: boolean
}>()
const emit = defineEmits<{
tap: [booking: BookingWithDetails]
cancel: [booking: BookingWithDetails]
}>()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function parseDate(dateStr: string): Date {
const normalized = dateStr.slice(0, 10)
const [y, m, d] = normalized.split('-').map(Number)
return new Date(y, m - 1, d)
}
const date = computed(() => parseDate(props.booking.timeSlot.date))
const dayNumber = computed(() => {
const d = date.value.getDate()
return d < 10 ? `0${d}` : String(d)
})
const weekdayLabel = computed(() => weekdays[date.value.getDay()])
const startTime = computed(() => props.booking.timeSlot.startTime.slice(0, 5))
const endTime = computed(() => props.booking.timeSlot.endTime.slice(0, 5))
const cardName = computed(() => props.booking.membership?.cardType?.name || '会员卡')
const statusLabel = computed(() => bookingStatusLabel(props.booking.status))
const tone = computed(() => bookingStatusStripeClass(props.booking.status))
const muted = computed(() => {
return props.booking.status === BookingStatus.CANCELLED || props.booking.status === BookingStatus.NO_SHOW
})
function handleTap() {
emit('tap', props.booking)
}
function handleCancel() {
emit('cancel', props.booking)
}
</script>
<style lang="scss" scoped>
.session {
position: relative;
display: flex;
align-items: stretch;
gap: 20rpx;
padding: 28rpx 24rpx;
margin: 0 32rpx 16rpx;
border-radius: 28rpx;
background: #fdfbf7;
border: 1rpx solid #ede5d8;
box-shadow:
0 1rpx 0 rgba(122, 99, 84, 0.02),
0 4rpx 16rpx rgba(122, 99, 84, 0.03);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&__date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
width: 80rpx;
flex-shrink: 0;
padding: 4rpx 0;
}
&__day {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 44rpx;
font-weight: 500;
line-height: 1;
color: #3a322b;
font-variant-numeric: tabular-nums;
}
&__weekday {
font-size: 19rpx;
color: #a89d92;
letter-spacing: 1rpx;
margin-top: 4rpx;
}
&__rail {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
width: 14rpx;
flex-shrink: 0;
padding: 6rpx 0;
}
&__dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #6e8b7d;
flex-shrink: 0;
}
&__line {
width: 1rpx;
flex: 1;
background: linear-gradient(
to bottom,
rgba(155, 138, 117, 0.3) 0%,
rgba(155, 138, 117, 0) 100%
);
}
// 状态颜色
&--stripe--pending &__dot { background: #b8967c; }
&--stripe--confirmed &__dot { background: #6e8b7d; }
&--stripe--completed &__dot { background: #6e8b7d; }
&--stripe--cancelled &__dot { background: #c4a09a; }
&--stripe--noshow &__dot { background: #b89a93; }
&__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
padding: 4rpx 0;
}
&__top {
display: flex;
align-items: baseline;
gap: 6rpx;
}
&__time {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 34rpx;
font-weight: 500;
color: #3a322b;
font-variant-numeric: tabular-nums;
}
&__time-end {
font-size: 22rpx;
color: #a89d92;
font-variant-numeric: tabular-nums;
}
&__membership {
font-size: 22rpx;
color: #786b61;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-top: 6rpx;
}
&__status {
display: flex;
align-items: center;
gap: 8rpx;
}
&__status-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #6e8b7d;
}
&__status-text {
font-size: 21rpx;
color: #786b61;
letter-spacing: 1rpx;
}
&--stripe--pending &__status-text { color: #957c65; }
&--stripe--pending &__status-dot { background: #b8967c; }
&--stripe--cancelled &__status-text { color: #a89d92; }
&--stripe--cancelled &__status-dot { background: #c4a09a; }
&--stripe--noshow &__status-text { color: #9c7a6e; }
&--stripe--noshow &__status-dot { background: #b89a93; }
&__cancel {
font-size: 22rpx;
color: #a89d92;
padding: 6rpx 0 6rpx 16rpx;
letter-spacing: 1rpx;
transition: color 0.2s ease;
}
&__cancel:active {
color: #9c7a6e;
}
// 已取消 / 未出席:弱化
&--muted {
background: #f5f0e8;
border-color: #e8e0d4;
.session__day {
color: #a89d92;
}
.session__time {
color: #a89d92;
}
.session__membership {
color: #a89d92;
}
.session__dot {
opacity: 0.5;
}
}
}
.session:active {
transform: scale(0.99);
box-shadow:
0 1rpx 0 rgba(122, 99, 84, 0.02),
0 2rpx 8rpx rgba(122, 99, 84, 0.04);
}
</style>