perf: 优化我的课程 UI
This commit is contained in:
78
packages/app/src/components/MonthDivider.vue
Normal file
78
packages/app/src/components/MonthDivider.vue
Normal 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>
|
||||
382
packages/app/src/components/NextSessionHero.vue
Normal file
382
packages/app/src/components/NextSessionHero.vue
Normal 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>
|
||||
288
packages/app/src/components/SessionRow.vue
Normal file
288
packages/app/src/components/SessionRow.vue
Normal 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>
|
||||
@@ -2,98 +2,88 @@
|
||||
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的课表" show-back />
|
||||
|
||||
<view class="schedule-hero">
|
||||
<view class="schedule-hero__copy">
|
||||
<text class="schedule-hero__eyebrow">Teaching Day</text>
|
||||
<text class="schedule-hero__title">按日查看当天课程与学员</text>
|
||||
<text class="schedule-hero__desc">只显示你当天有学员的课程,按时间顺序一屏速览。</text>
|
||||
<view class="calendar">
|
||||
<view class="calendar__heading">
|
||||
<picker mode="date" :value="selectedDate" @change="handlePickerChange">
|
||||
<view class="calendar__month">{{ monthLabel }}<text class="calendar__chevron">⌄</text></view>
|
||||
</picker>
|
||||
<button v-if="!isToday(selectedDate)" class="text-button" @tap="selectDate(formatDate(new Date()))">回到今天</button>
|
||||
<text v-else class="calendar__today">今天</text>
|
||||
</view>
|
||||
<view class="schedule-hero__meta">
|
||||
<text class="schedule-hero__meta-num">{{ summary.slotCount }}</text>
|
||||
<text class="schedule-hero__meta-label">节课程</text>
|
||||
<text class="schedule-hero__meta-sub">{{ summary.studentCount }} 位学员</text>
|
||||
<view class="week-navigation">
|
||||
<button class="week-arrow" aria-label="上一周" @tap="shiftWeek(-7)">‹</button>
|
||||
<text class="week-navigation__label">{{ weekLabel }}</text>
|
||||
<button class="week-arrow" aria-label="下一周" @tap="shiftWeek(7)">›</button>
|
||||
</view>
|
||||
<view class="week">
|
||||
<button v-for="day in weekDays" :key="day.date" class="day"
|
||||
:class="{ 'day--selected': day.date === selectedDate, 'day--today': isToday(day.date) }"
|
||||
:aria-label="`${day.date} ${day.label}${day.date === selectedDate ? ',已选中' : ''}`"
|
||||
@tap="selectDate(day.date)">
|
||||
<text class="day__label">{{ day.label }}</text>
|
||||
<text class="day__number">{{ day.number }}</text>
|
||||
<view class="day__dot" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-toolbar">
|
||||
<DateSelector v-model="selectedDate" variant="booking" @select="handleDateSelect" />
|
||||
<view class="schedule-toolbar__summary">
|
||||
<view class="schedule-toolbar__chip">
|
||||
<text class="schedule-toolbar__chip-label">{{ dateLabel }}</text>
|
||||
</view>
|
||||
<view class="schedule-toolbar__chip schedule-toolbar__chip--soft">
|
||||
<text class="schedule-toolbar__chip-label">{{ summaryRangeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="agenda-heading">
|
||||
<text class="agenda-heading__date">{{ dateLabel }}</text>
|
||||
<text class="agenda-heading__count">{{ loading ? '正在加载' : error ? '加载失败' : `${slots.length} 节课 · ${studentCount} 人次` }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="schedule-scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
>
|
||||
<view v-if="loading && !refreshing" class="schedule-skeleton">
|
||||
<view v-for="i in 3" :key="i" class="schedule-skeleton__card">
|
||||
<view class="schedule-skeleton__time" />
|
||||
<view class="schedule-skeleton__line schedule-skeleton__line--long" />
|
||||
<view class="schedule-skeleton__line schedule-skeleton__line--short" />
|
||||
<scroll-view class="schedule-scroll" scroll-y refresher-enabled :refresher-triggered="refreshing"
|
||||
:scroll-top="scrollTop" @scroll="handleScroll" @refresherrefresh="handleRefresh">
|
||||
<view v-if="loading" class="skeleton" aria-label="正在加载课表">
|
||||
<view v-for="i in 3" :key="i" class="skeleton__row"><view class="skeleton__time" /><view class="skeleton__body" /></view>
|
||||
</view>
|
||||
<view v-else-if="error" class="empty">
|
||||
<text class="empty__title">课表暂时未能加载</text>
|
||||
<text class="empty__description">{{ error }}</text>
|
||||
<button class="outline-button" @tap="loadSchedule(selectedDate)">重新加载</button>
|
||||
</view>
|
||||
|
||||
<view v-else-if="slots.length === 0" class="schedule-empty">
|
||||
<view class="schedule-empty__badge">休</view>
|
||||
<text class="schedule-empty__title">这一天没有已预约课程</text>
|
||||
<text class="schedule-empty__desc">当前只展示有学员的课程安排,空白日期不会出现占位时段。</text>
|
||||
<view v-else-if="!loggedIn || !isAdmin" class="empty">
|
||||
<text class="empty__title">{{ loggedIn ? '仅管理员可查看课表' : '请先登录' }}</text>
|
||||
<text class="empty__description">返回「我的」查看账号信息</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="schedule-list">
|
||||
<view v-for="slot in slots" :key="slot.slotId" class="schedule-card">
|
||||
<view class="schedule-card__rail" />
|
||||
|
||||
<view class="schedule-card__header">
|
||||
<view>
|
||||
<text class="schedule-card__time">{{ slot.startTime.slice(0, 5) }}</text>
|
||||
<text class="schedule-card__range">{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
|
||||
<view v-else-if="slots.length === 0" class="empty">
|
||||
<view class="empty__line" />
|
||||
<text class="empty__title">当天暂无预约课程</text>
|
||||
<text class="empty__description">选择其他日期,查看授课安排</text>
|
||||
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
|
||||
</view>
|
||||
<view class="schedule-card__count">
|
||||
<text class="schedule-card__count-num">{{ slot.students.length }}</text>
|
||||
<text class="schedule-card__count-label">人</text>
|
||||
<view v-else class="agenda">
|
||||
<view v-for="slot in slots" :key="slot.slotId" class="session">
|
||||
<view class="session__time">
|
||||
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
|
||||
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
|
||||
</view>
|
||||
<view class="session__roster">
|
||||
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人</text></view>
|
||||
<view v-for="student in slot.students" :key="student.bookingId" class="student">
|
||||
<view class="student__headline">
|
||||
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
|
||||
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="schedule-card__body">
|
||||
<view v-for="student in slot.students" :key="student.bookingId" class="student-row">
|
||||
<view class="student-row__avatar">{{ getNameInitial(student.nickname) }}</view>
|
||||
<view class="student-row__main">
|
||||
<view class="student-row__headline">
|
||||
<text class="student-row__name">{{ student.nickname || '未命名学员' }}</text>
|
||||
<text class="student-row__status" :class="statusClass(student.status)">
|
||||
{{ statusLabel(student.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="student.phone" class="student-row__phone">{{ maskPhone(student.phone) }}</text>
|
||||
<text v-else class="student-row__phone student-row__phone--muted">未绑定手机号</text>
|
||||
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)">
|
||||
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 ↗</text>
|
||||
</button>
|
||||
<text v-else class="student__no-phone">未留手机号</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="agenda__end">当天课程已全部显示</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { TeachingScheduleSlot } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getWeekdayLabel, isToday } from '../../utils/format'
|
||||
@@ -101,38 +91,90 @@ import { getSystemLayout } from '../../utils/system'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { teachingSchedule, loadingTeachingSchedule } = storeToRefs(bookingStore)
|
||||
const { loggedIn, isAdmin } = storeToRefs(userStore)
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
const { loggedIn, isAdmin } = storeToRefs(useUserStore())
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||
const selectedDate = ref(formatDate(new Date()))
|
||||
// Keep the rendered date and request result paired when dates are changed quickly.
|
||||
const slots = ref<TeachingScheduleSlot[]>([])
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const error = ref('')
|
||||
const scrollTop = ref(0)
|
||||
let currentScrollTop = 0
|
||||
let requestId = 0
|
||||
|
||||
const slots = computed<readonly TeachingScheduleSlot[]>(() => teachingSchedule.value)
|
||||
const loading = computed(() => loadingTeachingSchedule.value)
|
||||
|
||||
const summary = computed(() => ({
|
||||
slotCount: slots.value.length,
|
||||
studentCount: slots.value.reduce((sum, slot) => sum + slot.students.length, 0),
|
||||
}))
|
||||
|
||||
const dateLabel = computed(() => {
|
||||
const label = `${selectedDate.value.slice(5, 7)}月${selectedDate.value.slice(8, 10)}日 ${getWeekdayLabel(selectedDate.value)}`
|
||||
return isToday(selectedDate.value) ? `今天 · ${label}` : label
|
||||
function parseDate(value: string) {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
const monthLabel = computed(() => `${selectedDate.value.slice(0, 4)}年 ${Number(selectedDate.value.slice(5, 7))}月`)
|
||||
const dateLabel = computed(() => `${isToday(selectedDate.value) ? '今天 · ' : ''}${Number(selectedDate.value.slice(5, 7))}月${Number(selectedDate.value.slice(8, 10))}日`)
|
||||
const studentCount = computed(() => slots.value.reduce((sum, slot) => sum + slot.students.length, 0))
|
||||
const weekDays = computed(() => {
|
||||
const start = parseDate(selectedDate.value)
|
||||
start.setDate(start.getDate() - (start.getDay() + 6) % 7)
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(start)
|
||||
day.setDate(start.getDate() + index)
|
||||
const date = formatDate(day)
|
||||
return { date, number: day.getDate(), label: getWeekdayLabel(date).replace('周', '').replace('星期', '') }
|
||||
})
|
||||
})
|
||||
const weekLabel = computed(() => {
|
||||
const first = parseDate(weekDays.value[0].date)
|
||||
const last = parseDate(weekDays.value[6].date)
|
||||
return `${first.getMonth() + 1}月${first.getDate()}日 — ${last.getMonth() + 1}月${last.getDate()}日`
|
||||
})
|
||||
|
||||
const summaryRangeLabel = computed(() => {
|
||||
if (slots.value.length === 0) {
|
||||
return '暂无课程'
|
||||
onShow(() => { loadSchedule(selectedDate.value) })
|
||||
|
||||
function handlePickerChange(event: { detail: { value: string } }) {
|
||||
selectDate(event.detail.value)
|
||||
}
|
||||
function shiftWeek(days: number) {
|
||||
const date = parseDate(selectedDate.value)
|
||||
date.setDate(date.getDate() + days)
|
||||
selectDate(formatDate(date))
|
||||
}
|
||||
function handleScroll(event: { detail: { scrollTop: number } }) {
|
||||
currentScrollTop = event.detail.scrollTop
|
||||
}
|
||||
async function selectDate(date: string) {
|
||||
if (selectedDate.value === date) return
|
||||
selectedDate.value = date
|
||||
refreshing.value = false
|
||||
scrollTop.value = currentScrollTop
|
||||
await nextTick()
|
||||
scrollTop.value = 0
|
||||
loadSchedule(date)
|
||||
}
|
||||
async function handleRefresh() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try { await loadSchedule(selectedDate.value) }
|
||||
finally { refreshing.value = false }
|
||||
}
|
||||
async function loadSchedule(date: string) {
|
||||
const id = ++requestId
|
||||
error.value = ''
|
||||
slots.value = []
|
||||
if (!loggedIn.value || !isAdmin.value) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await bookingStore.fetchTeachingSchedule(date)
|
||||
if (id === requestId) slots.value = [...result].sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
} catch (err: unknown) {
|
||||
if (id === requestId) error.value = getErrorMessage(err, '请检查网络后重试')
|
||||
} finally {
|
||||
if (id === requestId) loading.value = false
|
||||
}
|
||||
|
||||
const first = slots.value[0]
|
||||
const last = slots.value[slots.value.length - 1]
|
||||
return `${first.startTime.slice(0, 5)} - ${last.endTime.slice(0, 5)}`
|
||||
})
|
||||
|
||||
}
|
||||
function formatPhone(phone: string) {
|
||||
return /^\d{11}$/.test(phone) ? `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7)}` : phone
|
||||
}
|
||||
function contactStudent(phone: string) {
|
||||
uni.makePhoneCall({ phoneNumber: phone })
|
||||
}
|
||||
const STATUS_LABELS: Record<BookingStatus, string> = {
|
||||
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||
[BookingStatus.CONFIRMED]: '已确认',
|
||||
@@ -140,443 +182,68 @@ const STATUS_LABELS: Record<BookingStatus, string> = {
|
||||
[BookingStatus.COMPLETED]: '已完成',
|
||||
[BookingStatus.NO_SHOW]: '未出席',
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (!loggedIn.value) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isAdmin.value) {
|
||||
uni.showToast({ title: '仅管理员可查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loadSchedule(selectedDate.value)
|
||||
})
|
||||
|
||||
function handleDateSelect(date: string) {
|
||||
selectedDate.value = date
|
||||
loadSchedule(date)
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await loadSchedule(selectedDate.value)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule(date: string) {
|
||||
try {
|
||||
await bookingStore.fetchTeachingSchedule(date)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function getNameInitial(name: string): string {
|
||||
const normalized = (name || '?').trim()
|
||||
return normalized.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7, 11)}`
|
||||
}
|
||||
|
||||
function statusLabel(status: BookingStatus): string {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
function statusClass(status: BookingStatus): string {
|
||||
return status === BookingStatus.PENDING_CONFIRMATION
|
||||
? 'student-row__status--pending'
|
||||
: 'student-row__status--confirmed'
|
||||
}
|
||||
function statusLabel(status: BookingStatus) { return STATUS_LABELS[status] ?? status }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.schedule-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(93, 140, 138, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, #f3ede6 0%, #f7f4ef 30%, #fbfaf7 100%);
|
||||
}
|
||||
|
||||
.schedule-hero {
|
||||
margin: 24rpx 24rpx 20rpx;
|
||||
padding: 32rpx 30rpx;
|
||||
border-radius: 32rpx;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(60, 86, 92, 0.96), rgba(108, 137, 127, 0.92)),
|
||||
#3e5b60;
|
||||
color: #f8f5ef;
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
box-shadow: 0 22rpx 60rpx rgba(55, 84, 82, 0.18);
|
||||
|
||||
&__copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
text-transform: uppercase;
|
||||
color: rgba(248, 245, 239, 0.7);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 38rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: rgba(248, 245, 239, 0.78);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
width: 164rpx;
|
||||
max-width: 100%;
|
||||
border-radius: 24rpx;
|
||||
padding: 22rpx 18rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
&__meta-num {
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&__meta-label,
|
||||
&__meta-sub {
|
||||
font-size: 22rpx;
|
||||
color: rgba(248, 245, 239, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding-bottom: 12rpx;
|
||||
background: linear-gradient(180deg, rgba(247, 244, 239, 0.94), rgba(247, 244, 239, 0.74));
|
||||
backdrop-filter: blur(14rpx);
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(93, 140, 138, 0.12);
|
||||
box-shadow: 0 10rpx 24rpx rgba(80, 92, 82, 0.08);
|
||||
|
||||
&--soft {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
&__chip-label {
|
||||
font-size: 22rpx;
|
||||
color: #5b6058;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-scroll {
|
||||
height: calc(100vh - v-bind(navBarHeight));
|
||||
}
|
||||
|
||||
.schedule-skeleton {
|
||||
padding: 12rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
|
||||
&__card {
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
&__time,
|
||||
&__line {
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, rgba(220, 223, 218, 0.7), rgba(239, 241, 238, 0.95), rgba(220, 223, 218, 0.7));
|
||||
background-size: 300% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
&__time {
|
||||
width: 180rpx;
|
||||
height: 38rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__line {
|
||||
height: 24rpx;
|
||||
margin-top: 14rpx;
|
||||
|
||||
&--long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-empty {
|
||||
margin: 40rpx 24rpx 0;
|
||||
border-radius: 32rpx;
|
||||
padding: 72rpx 40rpx;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
box-shadow: 0 18rpx 44rpx rgba(108, 122, 112, 0.08);
|
||||
|
||||
&__badge {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 32rpx;
|
||||
background: linear-gradient(145deg, #e8ddd2, #f5efe8);
|
||||
color: #7e7467;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 34rpx;
|
||||
color: #3f403c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
color: #9b958b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
padding: 12rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 30rpx;
|
||||
padding: 28rpx 28rpx 18rpx 40rpx;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 20rpx 48rpx rgba(83, 95, 86, 0.1);
|
||||
|
||||
&__rail {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
left: 18rpx;
|
||||
bottom: 24rpx;
|
||||
width: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(180deg, #5d8c8a, #d7c4b1);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
font-size: 46rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #304549;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&__range {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #8a8e86;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&__count {
|
||||
min-width: 116rpx;
|
||||
padding: 14rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #f3efe8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__count-num {
|
||||
font-size: 34rpx;
|
||||
color: #6e5b4f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__count-label {
|
||||
margin-left: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: #907d6f;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.student-row {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx 20rpx 20rpx 16rpx;
|
||||
border-radius: 22rpx;
|
||||
background: linear-gradient(135deg, rgba(246, 244, 239, 0.98), rgba(255, 255, 255, 0.9));
|
||||
|
||||
&__avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(145deg, #5d8c8a, #86a99d);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #313630;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__status {
|
||||
flex-shrink: 0;
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background: rgba(206, 164, 96, 0.14);
|
||||
color: #9b6e22;
|
||||
}
|
||||
|
||||
&--confirmed {
|
||||
background: rgba(93, 140, 138, 0.14);
|
||||
color: #376a69;
|
||||
}
|
||||
}
|
||||
|
||||
&__phone {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: #7b8179;
|
||||
|
||||
&--muted {
|
||||
color: #b0b3ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-bottom-space {
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.schedule-hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
&__meta {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-toolbar__summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-card__header,
|
||||
.student-row__headline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
background: #fbf9f6;
|
||||
color: #514943;
|
||||
}
|
||||
button { margin: 0; padding: 0; background: transparent; font-weight: 400; border-radius: 0; &::after { border: none; } }
|
||||
.calendar { flex-shrink: 0; padding: 22rpx 32rpx 20rpx; background: #fff; }
|
||||
.calendar__heading { display: flex; align-items: center; justify-content: space-between; min-height: 76rpx; }
|
||||
.calendar__month { padding: 16rpx 0; font-size: 36rpx; font-family: 'Songti SC', 'STSong', serif; }
|
||||
.calendar__chevron { margin-left: 16rpx; font-size: 26rpx; color: #81776f; }
|
||||
.text-button, .calendar__today { font-size: 24rpx; color: #526e62; }
|
||||
.text-button { line-height: 76rpx; padding-left: 24rpx; }
|
||||
.week-navigation { display: flex; align-items: center; justify-content: space-between; margin: 0 -12rpx 8rpx; }
|
||||
.week-navigation__label { font-size: 23rpx; color: #81776f; }
|
||||
.week-arrow { width: 80rpx; height: 76rpx; line-height: 70rpx; font-size: 40rpx; color: #70665e; }
|
||||
.week { display: flex; justify-content: space-between; gap: 6rpx; }
|
||||
.day { flex: 1; min-width: 0; height: 120rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; border-radius: 40rpx; color: #514943; }
|
||||
.day__label { font-size: 22rpx; color: #81776f; }
|
||||
.day__number { margin-top: 16rpx; font-size: 32rpx; font-variant-numeric: tabular-nums; }
|
||||
.day__dot { margin-top: 10rpx; height: 6rpx; width: 6rpx; border-radius: 50%; background: transparent; }
|
||||
.day--today .day__dot { background: #526e62; }
|
||||
.day--selected { background: #526e62; color: #fff; .day__label { color: #fff; } .day__dot { background: transparent; } }
|
||||
.day--selected.day--today .day__dot { background: #fff; }
|
||||
.agenda-heading { flex-shrink: 0; display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 32rpx; border-top: 1rpx solid #eee9e2; }
|
||||
.agenda-heading__date { font-size: 28rpx; font-weight: 500; }
|
||||
.agenda-heading__count { font-size: 23rpx; color: #81776f; }
|
||||
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
|
||||
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
|
||||
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
|
||||
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
|
||||
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
|
||||
.session__end { font-size: 24rpx; color: #687367; }
|
||||
.session__roster { min-width: 0; }
|
||||
.session__heading { display: flex; justify-content: space-between; padding: 24rpx 0 4rpx; font-size: 23rpx; color: #766d64; }
|
||||
.student { padding: 24rpx 0 16rpx; border-bottom: 1rpx solid #e8e2da; }
|
||||
.student:last-child { border-bottom: none; }
|
||||
.student__headline { display: flex; align-items: baseline; justify-content: space-between; gap: 12rpx; }
|
||||
.student__name { min-width: 0; font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; word-break: break-all; }
|
||||
.student__status { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 6rpx; background: #f2f0ec; font-size: 22rpx; line-height: 1.4; color: #766d64; }
|
||||
.student__status--confirmed { background: #edf3ed; color: #526e62; }
|
||||
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
|
||||
.student__status--no_show { background: #f8eeea; color: #a06456; }
|
||||
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
|
||||
.student__contact-label { color: #526e62; font-size: 22rpx; }
|
||||
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }
|
||||
.agenda__end { display: block; padding: 12rpx 0 24rpx; text-align: center; color: #81776f; font-size: 21rpx; }
|
||||
.empty { padding: 100rpx 48rpx 48rpx; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.empty__line { height: 48rpx; width: 1rpx; background: #b4c1b7; margin-bottom: 32rpx; }
|
||||
.empty__title { font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; }
|
||||
.empty__description { margin-top: 20rpx; font-size: 25rpx; color: #81776f; line-height: 1.7; }
|
||||
.outline-button { margin-top: 36rpx; padding: 0 36rpx; min-height: 80rpx; line-height: 80rpx; border: 1rpx solid #bcc7bf; border-radius: 8rpx; color: #526e62; font-size: 25rpx; }
|
||||
.skeleton { padding: 12rpx 32rpx; }
|
||||
.skeleton__row { display: flex; gap: 24rpx; margin-bottom: 40rpx; }
|
||||
.skeleton__time { width: 116rpx; height: 40rpx; background: #eae6df; border-radius: 4rpx; }
|
||||
.skeleton__body { flex: 1; height: 180rpx; background: #eeebe5; border-radius: 4rpx; }
|
||||
button:active { opacity: .7; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user