feat: 优化页面 UI 以及支持个人中心 练习足迹
This commit is contained in:
129
packages/app/src/components/PracticeActivityCard.vue
Normal file
129
packages/app/src/components/PracticeActivityCard.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<view class="practice">
|
||||
<view class="practice__heading">
|
||||
<view>
|
||||
<text class="practice__eyebrow">YOUR PRACTICE</text>
|
||||
<view class="practice__title">练习足迹<text class="practice__period">最近 30 天</text></view>
|
||||
</view>
|
||||
<view class="practice__summary"><text class="practice__number">{{ activity ? total : '—' }}</text><text>节课</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="error" class="practice__state">
|
||||
<text>暂时未能加载练习记录</text>
|
||||
<button class="practice__retry" @tap="load">重新加载</button>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="practice__range">
|
||||
<text>{{ activity ? formatDate(activity.days[0].date) : '正在读取练习记录' }}</text>
|
||||
<text>{{ activity ? formatDate(activity.days[29].date) + ' · 今天' : '' }}</text>
|
||||
</view>
|
||||
<view class="practice__grid" :class="{ 'practice__grid--loading': !activity }">
|
||||
<button v-for="(day, index) in cells" :key="day.date || index"
|
||||
class="practice__cell" :class="[
|
||||
`practice__cell--${Math.min(day.count, 3)}`,
|
||||
{ 'practice__cell--selected': selected === day.date && !!activity, 'practice__cell--today': index === 29 },
|
||||
]"
|
||||
:disabled="!activity" :aria-label="activity ? `${formatDate(day.date)},已完成 ${day.count} 节课` : '加载中'"
|
||||
@tap="selected = day.date">
|
||||
<text>{{ day.date ? Number(day.date.slice(-2)) : '' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="practice__footer">
|
||||
<text class="practice__detail">{{ detail }}</text>
|
||||
<view class="practice__legend">
|
||||
<text>0</text><view class="practice__swatch practice__cell--0" />
|
||||
<view class="practice__swatch practice__cell--1" /><view class="practice__swatch practice__cell--2" />
|
||||
<view class="practice__swatch practice__cell--3" /><text>3+ 节</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { PracticeActivity } from '@mp-pilates/shared'
|
||||
import { get } from '../utils/request'
|
||||
|
||||
const props = defineProps<{ refreshKey: number }>()
|
||||
const activity = ref<PracticeActivity | null>(null)
|
||||
const selected = ref('')
|
||||
const error = ref(false)
|
||||
let loading = false
|
||||
let disposed = false
|
||||
const cells = computed(() => activity.value?.days ?? Array.from({ length: 30 }, () => ({ date: '', count: 0 })))
|
||||
const total = computed(() => cells.value.reduce((sum, day) => sum + day.count, 0))
|
||||
const activeDays = computed(() => cells.value.filter(day => day.count > 0).length)
|
||||
const detail = computed(() => {
|
||||
if (!activity.value) return '每一次练习,都在积蓄力量'
|
||||
const day = activity.value.days.find(item => item.date === selected.value)
|
||||
if (day) return `${formatDate(day.date)} · ${day.count ? `已完成 ${day.count} 节课` : '暂无已完成课程'}`
|
||||
return total.value ? `已练习 ${activeDays.value} 天 · 点选查看` : '从下一次练习,点亮第一格'
|
||||
})
|
||||
function formatDate(date: string) {
|
||||
return `${Number(date.slice(5, 7))}月${Number(date.slice(8, 10))}日`
|
||||
}
|
||||
async function load() {
|
||||
if (loading || disposed) return
|
||||
loading = true
|
||||
error.value = false
|
||||
try {
|
||||
const result = await get<PracticeActivity>('/booking/my/activity')
|
||||
if (!disposed) {
|
||||
activity.value = result
|
||||
if (!result.days.some(day => day.date === selected.value)) selected.value = ''
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
activity.value = null
|
||||
error.value = true
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
watch(() => props.refreshKey, load)
|
||||
onUnmounted(() => { disposed = true })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.practice {
|
||||
margin: 28rpx $spacing-lg 0;
|
||||
padding: 28rpx;
|
||||
border: 1rpx solid #e2eae8;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, #fffefd, #f5f9f8);
|
||||
box-shadow: 0 8rpx 28rpx rgba(56, 88, 88, 0.035);
|
||||
color: #355b60;
|
||||
|
||||
&__heading, &__range, &__footer, &__legend { display: flex; align-items: center; justify-content: space-between; }
|
||||
&__eyebrow { font-family: Georgia, serif; font-size: 17rpx; letter-spacing: 3rpx; color: #75908f; }
|
||||
&__title { margin-top: 8rpx; font-size: 30rpx; font-weight: 600; letter-spacing: 2rpx; }
|
||||
&__period { margin-left: 16rpx; font-size: 21rpx; font-weight: 400; letter-spacing: 0; color: #788b88; }
|
||||
&__summary { display: flex; align-items: baseline; gap: 8rpx; font-size: 21rpx; color: #788b88; }
|
||||
&__number { font-family: Georgia, serif; font-size: 56rpx; line-height: 1; color: #355b60; font-variant-numeric: tabular-nums; }
|
||||
&__range { margin: 24rpx 0 12rpx; font-size: 20rpx; color: #788b88; }
|
||||
&__grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 9rpx; }
|
||||
&__cell {
|
||||
width: 100%; height: 44rpx; min-width: 0; margin: 0; padding: 0;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
border-radius: 8rpx; border: 2rpx solid transparent; box-sizing: border-box;
|
||||
font-size: 19rpx; line-height: 1; font-variant-numeric: tabular-nums;
|
||||
&::after { border: none; }
|
||||
&--0 { background: #e9eeeb; color: #768780; }
|
||||
&--1 { background: #b8d4ce; color: #355b60; }
|
||||
&--2 { background: #77a9a2; color: #193f43; }
|
||||
&--3 { background: #3e7575; color: #ffffff; }
|
||||
&--today { border-bottom-color: #355b60; }
|
||||
&--selected { box-shadow: 0 0 0 3rpx #fff, 0 0 0 5rpx #527f80; }
|
||||
}
|
||||
&__grid--loading { opacity: 0.5; }
|
||||
&__footer { margin-top: 22rpx; gap: 12rpx; flex-wrap: wrap; }
|
||||
&__detail { font-size: 21rpx; color: #627c77; }
|
||||
&__legend { gap: 5rpx; font-size: 18rpx; color: #788b88; }
|
||||
&__swatch { width: 13rpx; height: 13rpx; border-radius: 3rpx; }
|
||||
&__state { min-height: 200rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20rpx; font-size: 24rpx; color: #788b88; }
|
||||
&__retry { margin: 0; padding: 0 24rpx; line-height: 56rpx; font-size: 24rpx; color: #355b60; background: #e9eeeb; border-radius: 28rpx; &::after { border: none; } }
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="hero">
|
||||
<view class="hero-grain" />
|
||||
<view class="hero-inner">
|
||||
<view class="hero-avatar">
|
||||
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||||
@@ -19,12 +18,15 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-copy">
|
||||
<text class="hero-kicker">STUDIO LEDGER</text>
|
||||
<text class="hero-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||||
<text class="hero-phone">{{ detail.user.phone || '未绑定手机' }}</text>
|
||||
<text class="hero-openid" @tap="copyOpenid">{{ detail.user.openid }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="detail.user.openid" class="member-id" @tap="copyOpenid">
|
||||
<text class="member-id-label">微信标识</text>
|
||||
<text class="member-id-value">{{ detail.user.openid }}</text>
|
||||
<text class="member-id-copy">复制</text>
|
||||
</view>
|
||||
<view class="time-pair">
|
||||
<view class="time-cell">
|
||||
<text class="time-label">注册时间</text>
|
||||
@@ -38,14 +40,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section section--practice">
|
||||
<text class="section-label">上课情况</text>
|
||||
<view class="stats-strip">
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
|
||||
<text class="stat-name">累计预约</text>
|
||||
</view>
|
||||
<view class="stat">
|
||||
<view class="stat stat--completed">
|
||||
<text class="stat-num">{{ detail.stats.completedBookings }}</text>
|
||||
<text class="stat-name">已完成</text>
|
||||
</view>
|
||||
@@ -53,19 +55,24 @@
|
||||
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text>
|
||||
<text class="stat-name">已取消</text>
|
||||
</view>
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.noShowBookings }}</text>
|
||||
<text class="stat-name">未到课</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="noshow-hint">未到 {{ detail.stats.noShowBookings }} 次</text>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-heading">
|
||||
<text class="section-label">会员卡</text>
|
||||
<text class="section-note">{{ detail.memberships.length }} 张</text>
|
||||
</view>
|
||||
<view v-if="detail.memberships.length" class="card-list">
|
||||
<view
|
||||
v-for="card in detail.memberships"
|
||||
:key="card.id"
|
||||
class="mship"
|
||||
>
|
||||
<view class="mship-strip" :class="getCardGradientClass(card.cardType.type)" />
|
||||
<view class="mship-head">
|
||||
<view class="mship-titles">
|
||||
<text class="mship-name">{{ card.cardType.name }}</text>
|
||||
@@ -77,7 +84,11 @@
|
||||
</view>
|
||||
<view v-if="card.remainingTimes !== null" class="mship-times">
|
||||
<text class="mship-times-num">{{ card.remainingTimes }}</text>
|
||||
<text class="mship-times-unit">次剩余</text>
|
||||
<text class="mship-times-unit">次可用</text>
|
||||
</view>
|
||||
<view v-else class="mship-duration">
|
||||
<text class="mship-duration-title">有效期内使用</text>
|
||||
<text class="mship-times-unit">不限次数</text>
|
||||
</view>
|
||||
<view v-if="card.remainingTimes !== null && getMembershipTotalTimes(card)" class="progress">
|
||||
<view class="progress-bar">
|
||||
@@ -103,11 +114,14 @@
|
||||
</view>
|
||||
|
||||
<view class="section section--last">
|
||||
<view class="section-heading">
|
||||
<text class="section-label">即将上课</text>
|
||||
<text v-if="detail.upcomingBookings.length" class="section-note">{{ detail.upcomingBookings.length }} 节待上</text>
|
||||
</view>
|
||||
<view v-if="detail.upcomingBookings.length" class="upcoming-list">
|
||||
<view v-for="item in detail.upcomingBookings" :key="item.id" class="upcoming-row">
|
||||
<view class="upcoming-time">
|
||||
<text class="upcoming-date">{{ item.date.slice(5) }}</text>
|
||||
<text class="upcoming-date">{{ item.date.slice(5, 10).replace('-', ' / ') }}</text>
|
||||
<text class="upcoming-hour">{{ item.startTime.slice(0, 5) }}–{{ item.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
<view class="upcoming-meta">
|
||||
@@ -147,7 +161,6 @@ import { getSystemLayout } from '../../utils/system'
|
||||
import {
|
||||
formatDateTimeFull,
|
||||
getCardTypeLabel,
|
||||
getCardGradientClass,
|
||||
getMembershipProgressWidth,
|
||||
getMembershipUsedTimes,
|
||||
getMembershipTotalTimes,
|
||||
@@ -238,443 +251,150 @@ onShow(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
--ink: #514943;
|
||||
--muted: #8b817b;
|
||||
--line: #eee8e3;
|
||||
--sage: #617d73;
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
padding-bottom: 180rpx;
|
||||
box-sizing: border-box;
|
||||
background: #fbf9f6;
|
||||
color: var(--ink);
|
||||
padding-bottom: calc(152rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.skeleton-wrap {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-hero,
|
||||
.skeleton-block {
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
|
||||
.skeleton-wrap { padding: 28rpx 32rpx; }
|
||||
.skeleton-hero, .skeleton-block {
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-hero { height: 360rpx; margin-bottom: 24rpx; }
|
||||
.skeleton-block { height: 180rpx; margin-bottom: 20rpx; }
|
||||
.skeleton-hero { height: 300rpx; margin-bottom: 28rpx; }
|
||||
.skeleton-block { height: 180rpx; margin-bottom: 28rpx; }
|
||||
|
||||
.hero {
|
||||
margin: 20rpx 24rpx 8rpx;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #2c241c 0%, #4a4035 58%, #6b5a48 100%);
|
||||
position: relative;
|
||||
box-shadow: 0 18rpx 40rpx rgba(44, 36, 28, 0.22);
|
||||
margin: 28rpx 32rpx 0;
|
||||
padding: 32rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #f3eae5;
|
||||
}
|
||||
|
||||
.hero-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 18% 20%, rgba(169, 191, 204, 0.18), transparent 36%),
|
||||
radial-gradient(circle at 90% 80%, rgba(232, 168, 124, 0.16), transparent 32%);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 36rpx 32rpx 20rpx;
|
||||
}
|
||||
|
||||
.hero-inner { display: flex; align-items: center; gap: 24rpx; }
|
||||
.hero-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 28rpx;
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 3rpx solid rgba(255, 248, 240, 0.35);
|
||||
border: 6rpx solid #fcf8f4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img { width: 100%; height: 100%; }
|
||||
|
||||
.avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #7ba5be;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%; height: 100%; background: #e0cec4;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
font-family: 'Songti SC', 'STSong', serif;
|
||||
font-size: 44rpx; font-weight: 400; color: #735e53;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
.hero-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.hero-name { font-size: 38rpx; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.hero-phone { font-size: 26rpx; color: #84746b; letter-spacing: 1rpx; }
|
||||
.member-id {
|
||||
display: flex; align-items: center; gap: 14rpx;
|
||||
margin-top: 24rpx; min-height: 48rpx;
|
||||
font-size: 20rpx; color: #88786f;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 18rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: rgba(200, 216, 228, 0.72);
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
font-family: 'Songti SC', 'Noto Serif SC', Georgia, serif;
|
||||
}
|
||||
|
||||
.hero-phone,
|
||||
.hero-openid {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 248, 240, 0.68);
|
||||
}
|
||||
|
||||
.hero-openid {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-id-label { flex-shrink: 0; }
|
||||
.member-id-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.member-id-copy { flex-shrink: 0; color: #715a4e; padding: 8rpx 0 8rpx 8rpx; }
|
||||
.time-pair {
|
||||
position: relative;
|
||||
margin: 8rpx 20rpx 20rpx;
|
||||
background: rgba(255, 248, 240, 0.08);
|
||||
border: 1rpx solid rgba(255, 248, 240, 0.1);
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
padding: 18rpx 8rpx;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.time-rule {
|
||||
width: 1rpx;
|
||||
background: rgba(255, 248, 240, 0.16);
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 248, 240, 0.5);
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 22rpx;
|
||||
color: #fff8f0;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 28rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.section--last {
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: $text-hint;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex; gap: 20rpx;
|
||||
margin-top: 20rpx; padding-top: 24rpx;
|
||||
border-top: 1rpx solid #e4d8d0;
|
||||
}
|
||||
.time-cell { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.time-rule { width: 1rpx; background: #e4d8d0; }
|
||||
.time-label { font-size: 21rpx; color: #88786f; }
|
||||
.time-value { font-size: 22rpx; color: #6d5c52; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.section { padding: 36rpx 32rpx 0; }
|
||||
.section--last { padding-bottom: 24rpx; }
|
||||
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; }
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; }
|
||||
.section-note { font-size: 22rpx; color: var(--muted); }
|
||||
.stats-strip {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
padding: 28rpx 12rpx;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
display: flex; padding: 26rpx 0;
|
||||
border-radius: 24rpx; background: #ffffff;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: $text-primary;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.stat-name {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.noshow-hint {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mship {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
}
|
||||
|
||||
.mship-strip {
|
||||
height: 8rpx;
|
||||
}
|
||||
|
||||
.gradient--times { background: linear-gradient(90deg, #7ba5be, #a9bfcc); }
|
||||
.gradient--duration { background: linear-gradient(90deg, #7A9E7E, #b7cbb8); }
|
||||
.gradient--trial { background: linear-gradient(90deg, #C47A7A, #e8b4b4); }
|
||||
|
||||
.mship-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24rpx 24rpx 8rpx;
|
||||
}
|
||||
|
||||
.mship-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.mship-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.mship-type {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||||
align-items: center; gap: 12rpx; border-right: 1rpx solid var(--line);
|
||||
&:last-child { border-right: none; }
|
||||
}
|
||||
.stat-num { font-size: 40rpx; font-weight: 400; line-height: 1.1; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; font-variant-numeric: tabular-nums; }
|
||||
.stat-name { font-size: 22rpx; color: var(--muted); }
|
||||
.stat--completed { .stat-num, .stat-name { color: var(--sage); } }
|
||||
|
||||
.card-list { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.mship { padding: 28rpx; background: #fff; border-radius: 28rpx; border: 1rpx solid var(--line); }
|
||||
.mship-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20rpx; }
|
||||
.mship-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.mship-name { font-size: 29rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.mship-type { font-size: 21rpx; color: var(--muted); }
|
||||
.mship-status {
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba($success-color, 0.14);
|
||||
|
||||
&--expired,
|
||||
&--used_up { background: rgba($text-hint, 0.14); }
|
||||
flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx;
|
||||
background: #edf2ee; color: #617d73; line-height: 1.3;
|
||||
&--expired, &--used_up { background: #f2efec; color: #8b817b; }
|
||||
}
|
||||
|
||||
.mship-status-text {
|
||||
font-size: 20rpx;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.mship-times {
|
||||
padding: 4rpx 24rpx 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.mship-times-num {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: $text-primary;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.mship-times-unit {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.progress {
|
||||
padding: 12rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(180, 160, 130, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 8rpx;
|
||||
background: $accent-color;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.mship-status-text { font-size: 21rpx; }
|
||||
.mship-times { margin-top: 22rpx; display: flex; align-items: baseline; gap: 10rpx; }
|
||||
.mship-times-num { font-size: 52rpx; font-weight: 400; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; line-height: 1.2; }
|
||||
.mship-times-unit { font-size: 22rpx; color: var(--muted); }
|
||||
.mship-duration { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
|
||||
.mship-duration-title { font-size: 28rpx; color: #617d73; }
|
||||
.progress { margin-top: 18rpx; }
|
||||
.progress-bar { height: 6rpx; border-radius: 6rpx; background: #f0efea; overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 6rpx; background: #a5b8ab; }
|
||||
.progress-text { display: block; margin-top: 10rpx; font-size: 20rpx; color: var(--muted); }
|
||||
.mship-dates {
|
||||
padding: 16rpx 24rpx 24rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
color: $text-secondary;
|
||||
margin-top: 22rpx; padding-top: 18rpx; border-top: 1rpx solid #f3efeb;
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8rpx 20rpx;
|
||||
font-size: 21rpx; color: var(--muted); line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
padding: 48rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
border: 1rpx dashed rgba(180, 160, 130, 0.28);
|
||||
}
|
||||
|
||||
.empty-card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.empty-card-sub {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.empty-card-btn {
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $brand-color;
|
||||
}
|
||||
|
||||
.empty-card-btn-text {
|
||||
font-size: 24rpx;
|
||||
color: $primary-dark;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upcoming-list {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
background: #f4f2ed; border-radius: 28rpx; padding: 36rpx 28rpx;
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 12rpx;
|
||||
}
|
||||
.empty-card-title { font-size: 28rpx; font-weight: 500; }
|
||||
.empty-card-sub { font-size: 23rpx; color: var(--muted); line-height: 1.7; }
|
||||
.empty-card-btn { margin-top: 10rpx; padding: 14rpx 28rpx; border-radius: 999rpx; background: #e4ebe4; }
|
||||
.empty-card-btn-text { font-size: 24rpx; color: #526e62; }
|
||||
|
||||
.upcoming-list { background: #fff; border-radius: 28rpx; padding: 0 28rpx; }
|
||||
.upcoming-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
|
||||
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 24rpx;
|
||||
padding: 26rpx 0; border-bottom: 1rpx solid var(--line);
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.upcoming-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.upcoming-date {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.upcoming-hour {
|
||||
font-size: 24rpx;
|
||||
color: $accent-color;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.upcoming-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.upcoming-card {
|
||||
font-size: 24rpx;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.upcoming-status {
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.upcoming-empty {
|
||||
padding: 32rpx;
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.upcoming-empty-text {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
.upcoming-time { flex-shrink: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.upcoming-date { font-size: 28rpx; font-weight: 500; }
|
||||
.upcoming-hour { font-size: 23rpx; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.upcoming-meta { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 10rpx; }
|
||||
.upcoming-card { font-size: 24rpx; text-align: right; overflow-wrap: anywhere; }
|
||||
.upcoming-status { font-size: 21rpx; color: var(--sage); }
|
||||
.upcoming-empty { padding: 32rpx 28rpx; background: #f4f2ed; border-radius: 24rpx; }
|
||||
.upcoming-empty-text { font-size: 24rpx; color: var(--muted); }
|
||||
|
||||
.dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(250, 248, 245, 0.94);
|
||||
border-top: 1rpx solid rgba(180, 160, 130, 0.14);
|
||||
}
|
||||
|
||||
.dock-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dock-btn--ghost {
|
||||
background: #fff;
|
||||
border: 2rpx solid $brand-color;
|
||||
}
|
||||
|
||||
.dock-btn--solid {
|
||||
background: $brand-color;
|
||||
}
|
||||
|
||||
.dock-btn--disabled {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.dock-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-color;
|
||||
}
|
||||
|
||||
.dock-btn-text--solid {
|
||||
color: #fff8f0;
|
||||
position: fixed; z-index: 10; left: 0; right: 0; bottom: 0;
|
||||
display: flex; gap: 20rpx;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: #fbf9f6; border-top: 1rpx solid var(--line);
|
||||
}
|
||||
.dock-btn { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.dock-btn--ghost { background: #f0eae4; }
|
||||
.dock-btn--solid { flex: 1.35; background: #6b8276; }
|
||||
.dock-btn--disabled { background: #d9dfd8; .dock-btn-text--solid { color: #677467; } }
|
||||
.dock-btn-text { font-size: 28rpx; font-weight: 500; color: #78675c; }
|
||||
.dock-btn-text--solid { color: #fff; }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
||||
|
||||
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
||||
|
||||
<!-- Menu section: always visible -->
|
||||
<ProfileMenu
|
||||
:is-admin="isAdmin"
|
||||
@@ -33,6 +35,7 @@ import { useUserStore } from '../../stores/user'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
@@ -42,6 +45,7 @@ const bookingStore = useBookingStore()
|
||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||
const { upcomingBookings } = storeToRefs(bookingStore)
|
||||
|
||||
const activityRefreshKey = ref(0)
|
||||
const loginLoading = ref(false)
|
||||
const navBarHeight = ref(64)
|
||||
|
||||
@@ -74,6 +78,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
activityRefreshKey.value += 1
|
||||
if (loggedIn.value) {
|
||||
await Promise.all([
|
||||
userStore.fetchProfile(),
|
||||
|
||||
@@ -1542,4 +1542,44 @@ describe('BookingService', () => {
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
describe('getPracticeActivity', () => {
|
||||
afterEach(() => jest.restoreAllMocks())
|
||||
|
||||
it('uses China today across UTC midnight and counts scheduled dates without pagination', async () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-07T16:01:00Z'))
|
||||
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([
|
||||
{ timeSlot: { date: new Date('2026-08-10T00:00:00Z') } },
|
||||
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
|
||||
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
|
||||
])
|
||||
const result = await service.getPracticeActivity(MOCK_USER_ID)
|
||||
expect(result.days).toHaveLength(30)
|
||||
expect(result.days[0]).toEqual({ date: '2026-08-10', count: 1 })
|
||||
expect(result.days[29]).toEqual({ date: '2026-09-08', count: 2 })
|
||||
expect(result.days[1]).toEqual({ date: '2026-08-11', count: 0 })
|
||||
expect(prisma.booking.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: MOCK_USER_ID,
|
||||
status: BookingStatus.COMPLETED,
|
||||
timeSlot: { date: {
|
||||
gte: new Date('2026-08-10T00:00:00Z'),
|
||||
lt: new Date('2026-09-09T00:00:00Z'),
|
||||
} },
|
||||
},
|
||||
select: { timeSlot: { select: { date: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns every day with zero counts across a leap-year boundary', async () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2024-03-01T01:00:00Z'))
|
||||
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
|
||||
const result = await service.getPracticeActivity(MOCK_USER_ID)
|
||||
expect(result.days).toHaveLength(30)
|
||||
expect(result.days[0].date).toBe('2024-02-01')
|
||||
expect(result.days[28].date).toBe('2024-02-29')
|
||||
expect(result.days[29].date).toBe('2024-03-01')
|
||||
expect(result.days.every(day => day.count === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -42,6 +42,12 @@ export class BookingController {
|
||||
return this.bookingService.cancelBooking(userId, id)
|
||||
}
|
||||
|
||||
@Get('booking/my/activity')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getPracticeActivity(@CurrentUser('sub') userId: string) {
|
||||
return this.bookingService.getPracticeActivity(userId)
|
||||
}
|
||||
|
||||
@Get('booking/my/upcoming')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getUpcomingBookings(@CurrentUser('sub') userId: string) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MembershipStatus,
|
||||
TimeSlotStatus,
|
||||
type TeachingScheduleSlot,
|
||||
type PracticeActivity,
|
||||
} from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { MembershipService } from '../membership/membership.service'
|
||||
@@ -670,6 +671,34 @@ export class BookingService {
|
||||
|
||||
// ─── Get Upcoming Bookings ────────────────────────────────────────────────
|
||||
|
||||
async getPracticeActivity(userId: string): Promise<PracticeActivity> {
|
||||
// Slot dates are stored as UTC midnight date-only values. Determine today's
|
||||
// calendar date in China independently of the server's timezone.
|
||||
const dayMs = 86_400_000
|
||||
const today = new Date(Date.now() + 8 * 3_600_000).toISOString().slice(0, 10)
|
||||
const end = new Date(today + 'T00:00:00Z').getTime()
|
||||
const start = end - 29 * dayMs
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: BookingStatus.COMPLETED,
|
||||
timeSlot: { date: { gte: new Date(start), lt: new Date(end + dayMs) } },
|
||||
},
|
||||
select: { timeSlot: { select: { date: true } } },
|
||||
})
|
||||
const counts = new Map<string, number>()
|
||||
for (const booking of bookings) {
|
||||
const date = booking.timeSlot.date.toISOString().slice(0, 10)
|
||||
counts.set(date, (counts.get(date) ?? 0) + 1)
|
||||
}
|
||||
return {
|
||||
days: Array.from({ length: 30 }, (_, index) => {
|
||||
const date = new Date(start + index * dayMs).toISOString().slice(0, 10)
|
||||
return { date, count: counts.get(date) ?? 0 }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async getUpcomingBookings(userId: string): Promise<BookingWithRelations[]> {
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
@@ -78,6 +78,7 @@ export type {
|
||||
PublishDaySlotsDto,
|
||||
Booking,
|
||||
BookingWithDetails,
|
||||
PracticeActivity,
|
||||
BookingWithUser,
|
||||
TeachingScheduleStudent,
|
||||
TeachingScheduleSlot,
|
||||
|
||||
@@ -75,3 +75,8 @@ export interface AdminArrangeBookingDto {
|
||||
readonly membershipId: string
|
||||
readonly timeSlotId: string
|
||||
}
|
||||
|
||||
/** Completed classes by scheduled date, in Asia/Shanghai; includes today. */
|
||||
export interface PracticeActivity {
|
||||
readonly days: readonly { readonly date: string; readonly count: number }[]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, Schedule
|
||||
export type {
|
||||
Booking,
|
||||
BookingWithDetails,
|
||||
PracticeActivity,
|
||||
BookingWithUser,
|
||||
TeachingScheduleStudent,
|
||||
TeachingScheduleSlot,
|
||||
|
||||
Reference in New Issue
Block a user