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>
|
||||
Reference in New Issue
Block a user