feat: 个人信息页面支持展示今日上课进度
This commit is contained in:
180
packages/app/src/components/TodayLessonsCard.vue
Normal file
180
packages/app/src/components/TodayLessonsCard.vue
Normal 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>
|
||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user