feat: 个人信息页面支持展示今日上课进度

This commit is contained in:
richarjiang
2026-10-25 21:26:24 +08:00
parent d93cc921db
commit c04a9db627
3 changed files with 186 additions and 1 deletions

View File

@@ -0,0 +1,180 @@
<template>
<view class="today-lessons" :class="{ 'today-lessons--done': allDone }" hover-class="today-lessons--hover"
:aria-label="ariaLabel" @tap.stop="handleTap">
<view class="today-lessons__heading">
<text class="today-lessons__title">今日课程</text>
<text class="today-lessons__arrow">›</text>
</view>
<view v-if="!loaded && !error" class="today-lessons__skeleton">
<view class="today-lessons__skeleton-number" />
<view class="today-lessons__skeleton-bar" />
<view class="today-lessons__skeleton-hint" />
</view>
<view v-else-if="!loaded" class="today-lessons__error">
<text class="today-lessons__error-title">暂未更新</text>
<text class="today-lessons__hint">轻点重试</text>
</view>
<template v-else>
<view class="today-lessons__numbers">
<text class="today-lessons__completed">{{ stats.completed }}</text>
<text class="today-lessons__total">/ {{ stats.total }} 节</text>
</view>
<view class="today-lessons__track">
<template v-if="useSegments">
<view v-for="index in stats.total" :key="index" class="today-lessons__segment"
:class="{ 'today-lessons__segment--filled': index <= stats.completed }" />
</template>
<view v-else class="today-lessons__bar">
<view class="today-lessons__bar-fill" :style="{ width: `${percent}%` }" />
</view>
</view>
<text class="today-lessons__hint" :class="{ 'today-lessons__hint--alert': stats.awaiting > 0 }">{{ hint }}</text>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared'
import { get } from '../utils/request'
import { formatDate } from '../utils/format'
const props = defineProps<{ refreshKey: number }>()
const MAX_SEGMENTS = 8
const ACTIVE_STATUSES = new Set<BookingStatus>([BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED])
const slots = ref<readonly TeachingScheduleSlot[]>([])
const loaded = ref(false)
const error = ref(false)
const now = ref(Date.now())
let requestId = 0
let disposed = false
function toMinutes(time: string) {
return Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5))
}
// One time slot is one lesson, so a small-group class is never double-counted.
// Slots whose bookings were all cancelled are not lessons, and a lesson is
// done once at least one of its students has been verified (COMPLETED).
const stats = computed(() => {
const lessons = slots.value
.filter(slot => slot.students.some(student => student.status !== BookingStatus.CANCELLED))
.map(slot => ({
startTime: slot.startTime,
endTime: slot.endTime,
completed: slot.students.some(student => student.status === BookingStatus.COMPLETED),
active: slot.students.some(student => ACTIVE_STATUSES.has(student.status)),
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
const date = new Date(now.value)
const nowMinutes = date.getHours() * 60 + date.getMinutes()
const pending = lessons.filter(lesson => !lesson.completed && lesson.active)
return {
total: lessons.length,
completed: lessons.filter(lesson => lesson.completed).length,
awaiting: pending.filter(lesson => toMinutes(lesson.endTime) <= nowMinutes).length,
inProgress: pending.some(lesson => toMinutes(lesson.startTime) <= nowMinutes && toMinutes(lesson.endTime) > nowMinutes),
next: pending.find(lesson => toMinutes(lesson.startTime) > nowMinutes)?.startTime.slice(0, 5) ?? '',
}
})
const percent = computed(() => (stats.value.total ? Math.round(stats.value.completed / stats.value.total * 100) : 0))
const useSegments = computed(() => stats.value.total > 0 && stats.value.total <= MAX_SEGMENTS)
const allDone = computed(() => stats.value.total > 0 && stats.value.completed === stats.value.total)
const hint = computed(() => {
const { total, completed, awaiting, inProgress, next } = stats.value
if (total === 0) return '今日暂无课程'
if (allDone.value) return '已全部核销'
if (awaiting > 0) return `待核销 ${awaiting} 节`
if (inProgress) return '正在上课'
if (next) return `下一节 ${next}`
return `剩余 ${total - completed} 节`
})
const ariaLabel = computed(() => {
if (!loaded.value) return error.value ? '今日课程加载失败,轻点重试' : '正在加载今日课程'
return `今日课程共 ${stats.value.total} 节,已核销 ${stats.value.completed} 节,${hint.value}`
})
async function load() {
if (disposed) return
const id = ++requestId
error.value = false
now.value = Date.now()
try {
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date: formatDate(new Date()) })
if (disposed || id !== requestId) return
slots.value = Array.isArray(result) ? result : []
loaded.value = true
} catch {
// Keep the last good numbers on screen; only show the error state on first load.
if (!disposed && id === requestId) error.value = true
}
}
function handleTap() {
if (!loaded.value) {
if (error.value) load()
return
}
uni.navigateTo({ url: '/pages/profile/teaching-schedule' })
}
onMounted(load)
watch(() => props.refreshKey, load)
onUnmounted(() => { disposed = true })
</script>
<style scoped lang="scss">
.today-lessons {
flex-shrink: 0;
width: 228rpx;
min-height: 164rpx;
box-sizing: border-box;
padding: 20rpx 22rpx 18rpx;
display: flex;
flex-direction: column;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.7);
border: 1rpx solid rgba(255, 255, 255, 0.92);
box-shadow: 0 10rpx 28rpx rgba(122, 94, 78, 0.08);
color: #514943;
transition: transform 0.15s ease, opacity 0.15s ease;
&--hover { transform: scale(0.97); opacity: 0.88; }
&__heading { display: flex; align-items: center; justify-content: space-between; height: 30rpx; }
&__title { font-size: 21rpx; color: #8b7b70; }
&__arrow { font-size: 28rpx; line-height: 1; color: #b3a397; }
&__numbers { display: flex; align-items: baseline; gap: 6rpx; height: 56rpx; margin-top: 8rpx; }
&__completed { font-size: 50rpx; font-weight: 600; line-height: 1; color: #4f665a; font-variant-numeric: tabular-nums; }
&__total { font-size: 22rpx; color: #8b7b70; font-variant-numeric: tabular-nums; }
&__track { display: flex; gap: 6rpx; height: 10rpx; margin-top: 10rpx; }
&__segment { flex: 1; min-width: 0; height: 100%; border-radius: 999rpx; background: #ece3dc; transition: background 0.3s ease; }
&__segment--filled { background: linear-gradient(90deg, #8aa596, #6b8276); }
&__bar { position: relative; flex: 1; height: 100%; overflow: hidden; border-radius: 999rpx; background: #ece3dc; }
&__bar-fill { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 999rpx; background: linear-gradient(90deg, #8aa596, #6b8276); transition: width 0.3s ease; }
&__hint { margin-top: 12rpx; font-size: 20rpx; line-height: 1.3; color: #8b7b70; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
&__hint--alert { color: #b07a5e; }
&--done &__hint, &--done &__completed { color: #5f7a6b; }
&__skeleton { display: flex; flex-direction: column; gap: 14rpx; margin-top: 14rpx; }
&__skeleton-number { width: 96rpx; height: 44rpx; border-radius: 12rpx; background: #efe6df; }
&__skeleton-bar { width: 100%; height: 10rpx; border-radius: 999rpx; background: #efe6df; }
&__skeleton-hint { width: 120rpx; height: 20rpx; border-radius: 999rpx; background: #efe6df; }
&__error { display: flex; flex-direction: column; gap: 6rpx; margin-top: 16rpx; }
&__error-title { font-size: 26rpx; font-weight: 500; line-height: 1.4; color: #91766a; }
}
</style>

View File

@@ -32,7 +32,8 @@
</view> </view>
<text class="user-card__phone">{{ maskedPhone || '完善个人资料' }}</text> <text class="user-card__phone">{{ maskedPhone || '完善个人资料' }}</text>
</view> </view>
<text class="user-card__edit">资料 ›</text> <TodayLessonsCard v-if="showTodayLessons" :refresh-key="todayLessonsRefreshKey ?? 0" />
<text v-else class="user-card__edit">资料 ›</text>
</view> </view>
<!-- Logged in but profile still loading --> <!-- Logged in but profile still loading -->
@@ -69,6 +70,7 @@ import { ref, computed, watch } from 'vue'
import type { UserProfileResponse, MembershipWithCardType } from '@mp-pilates/shared' import type { UserProfileResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus } from '@mp-pilates/shared' import { MembershipStatus } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format' import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
import TodayLessonsCard from './TodayLessonsCard.vue'
const props = defineProps<{ const props = defineProps<{
loggedIn: boolean loggedIn: boolean
@@ -80,6 +82,8 @@ const props = defineProps<{
membershipsError?: boolean membershipsError?: boolean
memberships?: readonly MembershipWithCardType[] memberships?: readonly MembershipWithCardType[]
loading?: boolean loading?: boolean
showTodayLessons?: boolean
todayLessonsRefreshKey?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -7,6 +7,7 @@
<!-- User card --> <!-- User card -->
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :memberships="memberships" :now="membershipNow" <UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :memberships="memberships" :now="membershipNow"
:memberships-loading="membershipsLoading" :memberships-loaded="membershipsLoaded" :memberships-error="membershipsError" :memberships-loading="membershipsLoading" :memberships-loaded="membershipsLoaded" :memberships-error="membershipsError"
:show-today-lessons="loggedIn && isAdmin" :today-lessons-refresh-key="activityRefreshKey"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" /> :loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
<InviteCard /> <InviteCard />