feat(app): 新增个人中心课表视图
This commit is contained in:
582
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
582
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
@@ -0,0 +1,582 @@
|
||||
<template>
|
||||
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的课表" show-back />
|
||||
|
||||
<view class="schedule-hero">
|
||||
<view class="schedule-hero__copy">
|
||||
<text class="schedule-hero__eyebrow">Teaching Day</text>
|
||||
<text class="schedule-hero__title">按日查看当天课程与学员</text>
|
||||
<text class="schedule-hero__desc">只显示你当天有学员的课程,按时间顺序一屏速览。</text>
|
||||
</view>
|
||||
<view class="schedule-hero__meta">
|
||||
<text class="schedule-hero__meta-num">{{ summary.slotCount }}</text>
|
||||
<text class="schedule-hero__meta-label">节课程</text>
|
||||
<text class="schedule-hero__meta-sub">{{ summary.studentCount }} 位学员</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-toolbar">
|
||||
<DateSelector v-model="selectedDate" variant="booking" @select="handleDateSelect" />
|
||||
<view class="schedule-toolbar__summary">
|
||||
<view class="schedule-toolbar__chip">
|
||||
<text class="schedule-toolbar__chip-label">{{ dateLabel }}</text>
|
||||
</view>
|
||||
<view class="schedule-toolbar__chip schedule-toolbar__chip--soft">
|
||||
<text class="schedule-toolbar__chip-label">{{ summaryRangeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="schedule-scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
>
|
||||
<view v-if="loading && !refreshing" class="schedule-skeleton">
|
||||
<view v-for="i in 3" :key="i" class="schedule-skeleton__card">
|
||||
<view class="schedule-skeleton__time" />
|
||||
<view class="schedule-skeleton__line schedule-skeleton__line--long" />
|
||||
<view class="schedule-skeleton__line schedule-skeleton__line--short" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="slots.length === 0" class="schedule-empty">
|
||||
<view class="schedule-empty__badge">休</view>
|
||||
<text class="schedule-empty__title">这一天没有已预约课程</text>
|
||||
<text class="schedule-empty__desc">当前只展示有学员的课程安排,空白日期不会出现占位时段。</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="schedule-list">
|
||||
<view v-for="slot in slots" :key="slot.slotId" class="schedule-card">
|
||||
<view class="schedule-card__rail" />
|
||||
|
||||
<view class="schedule-card__header">
|
||||
<view>
|
||||
<text class="schedule-card__time">{{ slot.startTime.slice(0, 5) }}</text>
|
||||
<text class="schedule-card__range">{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
<view class="schedule-card__count">
|
||||
<text class="schedule-card__count-num">{{ slot.students.length }}</text>
|
||||
<text class="schedule-card__count-label">人</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-card__body">
|
||||
<view v-for="student in slot.students" :key="student.bookingId" class="student-row">
|
||||
<view class="student-row__avatar">{{ getNameInitial(student.nickname) }}</view>
|
||||
<view class="student-row__main">
|
||||
<view class="student-row__headline">
|
||||
<text class="student-row__name">{{ student.nickname || '未命名学员' }}</text>
|
||||
<text class="student-row__status" :class="statusClass(student.status)">
|
||||
{{ statusLabel(student.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="student.phone" class="student-row__phone">{{ maskPhone(student.phone) }}</text>
|
||||
<text v-else class="student-row__phone student-row__phone--muted">未绑定手机号</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { TeachingScheduleSlot } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getWeekdayLabel, isToday } from '../../utils/format'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { teachingSchedule, loadingTeachingSchedule } = storeToRefs(bookingStore)
|
||||
const { loggedIn, isAdmin } = storeToRefs(userStore)
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
const selectedDate = ref(formatDate(new Date()))
|
||||
const refreshing = ref(false)
|
||||
|
||||
const slots = computed<readonly TeachingScheduleSlot[]>(() => teachingSchedule.value)
|
||||
const loading = computed(() => loadingTeachingSchedule.value)
|
||||
|
||||
const summary = computed(() => ({
|
||||
slotCount: slots.value.length,
|
||||
studentCount: slots.value.reduce((sum, slot) => sum + slot.students.length, 0),
|
||||
}))
|
||||
|
||||
const dateLabel = computed(() => {
|
||||
const label = `${selectedDate.value.slice(5, 7)}月${selectedDate.value.slice(8, 10)}日 ${getWeekdayLabel(selectedDate.value)}`
|
||||
return isToday(selectedDate.value) ? `今天 · ${label}` : label
|
||||
})
|
||||
|
||||
const summaryRangeLabel = computed(() => {
|
||||
if (slots.value.length === 0) {
|
||||
return '暂无课程'
|
||||
}
|
||||
|
||||
const first = slots.value[0]
|
||||
const last = slots.value[slots.value.length - 1]
|
||||
return `${first.startTime.slice(0, 5)} - ${last.endTime.slice(0, 5)}`
|
||||
})
|
||||
|
||||
const STATUS_LABELS: Record<BookingStatus, string> = {
|
||||
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||
[BookingStatus.CONFIRMED]: '已确认',
|
||||
[BookingStatus.CANCELLED]: '已取消',
|
||||
[BookingStatus.COMPLETED]: '已完成',
|
||||
[BookingStatus.NO_SHOW]: '未出席',
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (!loggedIn.value) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isAdmin.value) {
|
||||
uni.showToast({ title: '仅管理员可查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loadSchedule(selectedDate.value)
|
||||
})
|
||||
|
||||
function handleDateSelect(date: string) {
|
||||
selectedDate.value = date
|
||||
loadSchedule(date)
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await loadSchedule(selectedDate.value)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule(date: string) {
|
||||
try {
|
||||
await bookingStore.fetchTeachingSchedule(date)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function getNameInitial(name: string): string {
|
||||
const normalized = (name || '?').trim()
|
||||
return normalized.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7, 11)}`
|
||||
}
|
||||
|
||||
function statusLabel(status: BookingStatus): string {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
function statusClass(status: BookingStatus): string {
|
||||
return status === BookingStatus.PENDING_CONFIRMATION
|
||||
? 'student-row__status--pending'
|
||||
: 'student-row__status--confirmed'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.schedule-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(93, 140, 138, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, #f3ede6 0%, #f7f4ef 30%, #fbfaf7 100%);
|
||||
}
|
||||
|
||||
.schedule-hero {
|
||||
margin: 24rpx 24rpx 20rpx;
|
||||
padding: 32rpx 30rpx;
|
||||
border-radius: 32rpx;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(60, 86, 92, 0.96), rgba(108, 137, 127, 0.92)),
|
||||
#3e5b60;
|
||||
color: #f8f5ef;
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
box-shadow: 0 22rpx 60rpx rgba(55, 84, 82, 0.18);
|
||||
|
||||
&__copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
text-transform: uppercase;
|
||||
color: rgba(248, 245, 239, 0.7);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 38rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: rgba(248, 245, 239, 0.78);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
width: 164rpx;
|
||||
max-width: 100%;
|
||||
border-radius: 24rpx;
|
||||
padding: 22rpx 18rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
&__meta-num {
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&__meta-label,
|
||||
&__meta-sub {
|
||||
font-size: 22rpx;
|
||||
color: rgba(248, 245, 239, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding-bottom: 12rpx;
|
||||
background: linear-gradient(180deg, rgba(247, 244, 239, 0.94), rgba(247, 244, 239, 0.74));
|
||||
backdrop-filter: blur(14rpx);
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(93, 140, 138, 0.12);
|
||||
box-shadow: 0 10rpx 24rpx rgba(80, 92, 82, 0.08);
|
||||
|
||||
&--soft {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
&__chip-label {
|
||||
font-size: 22rpx;
|
||||
color: #5b6058;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-scroll {
|
||||
height: calc(100vh - v-bind(navBarHeight));
|
||||
}
|
||||
|
||||
.schedule-skeleton {
|
||||
padding: 12rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
|
||||
&__card {
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
&__time,
|
||||
&__line {
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, rgba(220, 223, 218, 0.7), rgba(239, 241, 238, 0.95), rgba(220, 223, 218, 0.7));
|
||||
background-size: 300% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
&__time {
|
||||
width: 180rpx;
|
||||
height: 38rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__line {
|
||||
height: 24rpx;
|
||||
margin-top: 14rpx;
|
||||
|
||||
&--long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-empty {
|
||||
margin: 40rpx 24rpx 0;
|
||||
border-radius: 32rpx;
|
||||
padding: 72rpx 40rpx;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
box-shadow: 0 18rpx 44rpx rgba(108, 122, 112, 0.08);
|
||||
|
||||
&__badge {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 32rpx;
|
||||
background: linear-gradient(145deg, #e8ddd2, #f5efe8);
|
||||
color: #7e7467;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 34rpx;
|
||||
color: #3f403c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
color: #9b958b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
padding: 12rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 30rpx;
|
||||
padding: 28rpx 28rpx 18rpx 40rpx;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 20rpx 48rpx rgba(83, 95, 86, 0.1);
|
||||
|
||||
&__rail {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
left: 18rpx;
|
||||
bottom: 24rpx;
|
||||
width: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(180deg, #5d8c8a, #d7c4b1);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
font-size: 46rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #304549;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&__range {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #8a8e86;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&__count {
|
||||
min-width: 116rpx;
|
||||
padding: 14rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #f3efe8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__count-num {
|
||||
font-size: 34rpx;
|
||||
color: #6e5b4f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__count-label {
|
||||
margin-left: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: #907d6f;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.student-row {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx 20rpx 20rpx 16rpx;
|
||||
border-radius: 22rpx;
|
||||
background: linear-gradient(135deg, rgba(246, 244, 239, 0.98), rgba(255, 255, 255, 0.9));
|
||||
|
||||
&__avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(145deg, #5d8c8a, #86a99d);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #313630;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__status {
|
||||
flex-shrink: 0;
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background: rgba(206, 164, 96, 0.14);
|
||||
color: #9b6e22;
|
||||
}
|
||||
|
||||
&--confirmed {
|
||||
background: rgba(93, 140, 138, 0.14);
|
||||
color: #376a69;
|
||||
}
|
||||
}
|
||||
|
||||
&__phone {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: #7b8179;
|
||||
|
||||
&--muted {
|
||||
color: #b0b3ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-bottom-space {
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.schedule-hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
&__meta {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-toolbar__summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-card__header,
|
||||
.student-row__headline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user