feat: 优化页面 UI 以及支持个人中心 练习足迹

This commit is contained in:
richarjiang
2026-09-08 11:26:46 +08:00
parent 87d946adb5
commit 301f9ae385
9 changed files with 347 additions and 411 deletions

View 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>