- 后端 getTeachingScheduleByDate 在查询当天时不再以 PENDING/CONFIRMED 过滤 timeSlot 也不限制 include.bookings 的状态,但仍保留 EXISTS 守卫过滤空 slot - 其他日期维持原「只看待上课」语义 - 抽出私有 formatLocalDate 工具方法,消除服务端 YYYY-MM-DD 构造的 3 处重复 - 前端 teaching-schedule 的 session 卡片可点击跳到 /pages/booking/detail?slotId= - 补全 student__status--completed / --cancelled 样式
257 lines
14 KiB
Vue
257 lines
14 KiB
Vue
<template>
|
||
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
|
||
<CustomNavBar title="我的课表" show-back />
|
||
|
||
<view class="calendar">
|
||
<view class="calendar__heading">
|
||
<picker mode="date" :value="selectedDate" @change="handlePickerChange">
|
||
<view class="calendar__month">{{ monthLabel }}<text class="calendar__chevron">⌄</text></view>
|
||
</picker>
|
||
<button v-if="!isToday(selectedDate)" class="text-button" @tap="selectDate(formatDate(new Date()))">回到今天</button>
|
||
<text v-else class="calendar__today">今天</text>
|
||
</view>
|
||
<view class="week-navigation">
|
||
<button class="week-arrow" aria-label="上一周" @tap="shiftWeek(-7)">‹</button>
|
||
<text class="week-navigation__label">{{ weekLabel }}</text>
|
||
<button class="week-arrow" aria-label="下一周" @tap="shiftWeek(7)">›</button>
|
||
</view>
|
||
<view class="week">
|
||
<button v-for="day in weekDays" :key="day.date" class="day"
|
||
:class="{ 'day--selected': day.date === selectedDate, 'day--today': isToday(day.date) }"
|
||
:aria-label="`${day.date} ${day.label}${day.date === selectedDate ? ',已选中' : ''}`"
|
||
@tap="selectDate(day.date)">
|
||
<text class="day__label">{{ day.label }}</text>
|
||
<text class="day__number">{{ day.number }}</text>
|
||
<view class="day__dot" />
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="agenda-heading">
|
||
<text class="agenda-heading__date">{{ dateLabel }}</text>
|
||
<text class="agenda-heading__count">{{ loading ? '正在加载' : error ? '加载失败' : `${slots.length} 节课 · ${studentCount} 人次` }}</text>
|
||
</view>
|
||
|
||
<scroll-view class="schedule-scroll" scroll-y refresher-enabled :refresher-triggered="refreshing"
|
||
:scroll-top="scrollTop" @scroll="handleScroll" @refresherrefresh="handleRefresh">
|
||
<view v-if="loading" class="skeleton" aria-label="正在加载课表">
|
||
<view v-for="i in 3" :key="i" class="skeleton__row"><view class="skeleton__time" /><view class="skeleton__body" /></view>
|
||
</view>
|
||
<view v-else-if="error" class="empty">
|
||
<text class="empty__title">课表暂时未能加载</text>
|
||
<text class="empty__description">{{ error }}</text>
|
||
<button class="outline-button" @tap="loadSchedule(selectedDate)">重新加载</button>
|
||
</view>
|
||
<view v-else-if="!loggedIn || !isAdmin" class="empty">
|
||
<text class="empty__title">{{ loggedIn ? '仅管理员可查看课表' : '请先登录' }}</text>
|
||
<text class="empty__description">返回「我的」查看账号信息</text>
|
||
</view>
|
||
<view v-else-if="slots.length === 0" class="empty">
|
||
<view class="empty__line" />
|
||
<text class="empty__title">当天暂无预约课程</text>
|
||
<text class="empty__description">选择其他日期,查看授课安排</text>
|
||
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
|
||
</view>
|
||
<view v-else class="agenda">
|
||
<view v-for="slot in slots" :key="slot.slotId" class="session" hover-class="session--hover"
|
||
:aria-label="`${slot.startTime.slice(0, 5)} 至 ${slot.endTime.slice(0, 5)},${slot.students.length} 人`" @tap="openSlot(slot.slotId)">
|
||
<view class="session__time">
|
||
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
|
||
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
|
||
</view>
|
||
<view class="session__roster">
|
||
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人 ›</text></view>
|
||
<view v-for="student in slot.students" :key="student.bookingId" class="student">
|
||
<view class="student__headline">
|
||
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
|
||
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
|
||
</view>
|
||
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap.stop="contactStudent(student.phone)">
|
||
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 ↗</text>
|
||
</button>
|
||
<text v-else class="student__no-phone">未留手机号</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<text class="agenda__end">当天课程已全部显示</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, ref } from 'vue'
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import { storeToRefs } from 'pinia'
|
||
import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared'
|
||
import CustomNavBar from '../../components/CustomNavBar.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 { loggedIn, isAdmin } = storeToRefs(useUserStore())
|
||
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||
const selectedDate = ref(formatDate(new Date()))
|
||
// Keep the rendered date and request result paired when dates are changed quickly.
|
||
const slots = ref<TeachingScheduleSlot[]>([])
|
||
const loading = ref(false)
|
||
const refreshing = ref(false)
|
||
const error = ref('')
|
||
const scrollTop = ref(0)
|
||
let currentScrollTop = 0
|
||
let requestId = 0
|
||
|
||
function parseDate(value: string) {
|
||
const [year, month, day] = value.split('-').map(Number)
|
||
return new Date(year, month - 1, day)
|
||
}
|
||
const monthLabel = computed(() => `${selectedDate.value.slice(0, 4)}年 ${Number(selectedDate.value.slice(5, 7))}月`)
|
||
const dateLabel = computed(() => `${isToday(selectedDate.value) ? '今天 · ' : ''}${Number(selectedDate.value.slice(5, 7))}月${Number(selectedDate.value.slice(8, 10))}日`)
|
||
const studentCount = computed(() => slots.value.reduce((sum, slot) => sum + slot.students.length, 0))
|
||
const weekDays = computed(() => {
|
||
const start = parseDate(selectedDate.value)
|
||
start.setDate(start.getDate() - (start.getDay() + 6) % 7)
|
||
return Array.from({ length: 7 }, (_, index) => {
|
||
const day = new Date(start)
|
||
day.setDate(start.getDate() + index)
|
||
const date = formatDate(day)
|
||
return { date, number: day.getDate(), label: getWeekdayLabel(date).replace('周', '').replace('星期', '') }
|
||
})
|
||
})
|
||
const weekLabel = computed(() => {
|
||
const first = parseDate(weekDays.value[0].date)
|
||
const last = parseDate(weekDays.value[6].date)
|
||
return `${first.getMonth() + 1}月${first.getDate()}日 — ${last.getMonth() + 1}月${last.getDate()}日`
|
||
})
|
||
|
||
onShow(() => { loadSchedule(selectedDate.value) })
|
||
|
||
function handlePickerChange(event: { detail: { value: string } }) {
|
||
selectDate(event.detail.value)
|
||
}
|
||
function shiftWeek(days: number) {
|
||
const date = parseDate(selectedDate.value)
|
||
date.setDate(date.getDate() + days)
|
||
selectDate(formatDate(date))
|
||
}
|
||
function handleScroll(event: { detail: { scrollTop: number } }) {
|
||
currentScrollTop = event.detail.scrollTop
|
||
}
|
||
async function selectDate(date: string) {
|
||
if (selectedDate.value === date) return
|
||
selectedDate.value = date
|
||
refreshing.value = false
|
||
scrollTop.value = currentScrollTop
|
||
await nextTick()
|
||
scrollTop.value = 0
|
||
loadSchedule(date)
|
||
}
|
||
async function handleRefresh() {
|
||
if (refreshing.value) return
|
||
refreshing.value = true
|
||
try { await loadSchedule(selectedDate.value) }
|
||
finally { refreshing.value = false }
|
||
}
|
||
async function loadSchedule(date: string) {
|
||
const id = ++requestId
|
||
error.value = ''
|
||
slots.value = []
|
||
if (!loggedIn.value || !isAdmin.value) { loading.value = false; return }
|
||
loading.value = true
|
||
try {
|
||
const result = await bookingStore.fetchTeachingSchedule(date)
|
||
if (id === requestId) slots.value = [...result].sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||
} catch (err: unknown) {
|
||
if (id === requestId) error.value = getErrorMessage(err, '请检查网络后重试')
|
||
} finally {
|
||
if (id === requestId) loading.value = false
|
||
}
|
||
}
|
||
function formatPhone(phone: string) {
|
||
return /^\d{11}$/.test(phone) ? `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7)}` : phone
|
||
}
|
||
function contactStudent(phone: string) {
|
||
uni.makePhoneCall({ phoneNumber: phone })
|
||
}
|
||
function openSlot(slotId: string) {
|
||
uni.navigateTo({ url: `/pages/booking/detail?slotId=${encodeURIComponent(slotId)}` })
|
||
}
|
||
const STATUS_LABELS: Record<BookingStatus, string> = {
|
||
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||
[BookingStatus.CONFIRMED]: '已确认',
|
||
[BookingStatus.CANCELLED]: '已取消',
|
||
[BookingStatus.COMPLETED]: '已完成',
|
||
[BookingStatus.NO_SHOW]: '未出席',
|
||
}
|
||
function statusLabel(status: BookingStatus) { return STATUS_LABELS[status] ?? status }
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.schedule-page {
|
||
height: 100vh;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: #fbf9f6;
|
||
color: #514943;
|
||
}
|
||
button { margin: 0; padding: 0; background: transparent; font-weight: 400; border-radius: 0; &::after { border: none; } }
|
||
.calendar { flex-shrink: 0; padding: 22rpx 32rpx 20rpx; background: #fff; }
|
||
.calendar__heading { display: flex; align-items: center; justify-content: space-between; min-height: 76rpx; }
|
||
.calendar__month { padding: 16rpx 0; font-size: 36rpx; font-family: 'Songti SC', 'STSong', serif; }
|
||
.calendar__chevron { margin-left: 16rpx; font-size: 26rpx; color: #81776f; }
|
||
.text-button, .calendar__today { font-size: 24rpx; color: #526e62; }
|
||
.text-button { line-height: 76rpx; padding-left: 24rpx; }
|
||
.week-navigation { display: flex; align-items: center; justify-content: space-between; margin: 0 -12rpx 8rpx; }
|
||
.week-navigation__label { font-size: 23rpx; color: #81776f; }
|
||
.week-arrow { width: 80rpx; height: 76rpx; line-height: 70rpx; font-size: 40rpx; color: #70665e; }
|
||
.week { display: flex; justify-content: space-between; gap: 6rpx; }
|
||
.day { flex: 1; min-width: 0; height: 120rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; border-radius: 40rpx; color: #514943; }
|
||
.day__label { font-size: 22rpx; color: #81776f; }
|
||
.day__number { margin-top: 16rpx; font-size: 32rpx; font-variant-numeric: tabular-nums; }
|
||
.day__dot { margin-top: 10rpx; height: 6rpx; width: 6rpx; border-radius: 50%; background: transparent; }
|
||
.day--today .day__dot { background: #526e62; }
|
||
.day--selected { background: #526e62; color: #fff; .day__label { color: #fff; } .day__dot { background: transparent; } }
|
||
.day--selected.day--today .day__dot { background: #fff; }
|
||
.agenda-heading { flex-shrink: 0; display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 32rpx; border-top: 1rpx solid #eee9e2; }
|
||
.agenda-heading__date { font-size: 28rpx; font-weight: 500; }
|
||
.agenda-heading__count { font-size: 23rpx; color: #81776f; }
|
||
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
|
||
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
|
||
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
|
||
.session--hover { background: #f4f1ec; }
|
||
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
|
||
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
|
||
.session__end { font-size: 24rpx; color: #687367; }
|
||
.session__roster { min-width: 0; }
|
||
.session__heading { display: flex; justify-content: space-between; padding: 24rpx 0 4rpx; font-size: 23rpx; color: #766d64; }
|
||
.student { padding: 24rpx 0 16rpx; border-bottom: 1rpx solid #e8e2da; }
|
||
.student:last-child { border-bottom: none; }
|
||
.student__headline { display: flex; align-items: baseline; justify-content: space-between; gap: 12rpx; }
|
||
.student__name { min-width: 0; font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; word-break: break-all; }
|
||
.student__status { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 6rpx; background: #f2f0ec; font-size: 22rpx; line-height: 1.4; color: #766d64; }
|
||
.student__status--confirmed { background: #edf3ed; color: #526e62; }
|
||
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
|
||
.student__status--no_show { background: #f8eeea; color: #a06456; }
|
||
.student__status--completed { background: #e8e8e1; color: #5b6660; }
|
||
.student__status--cancelled { background: #efe5e0; color: #8c5d4f; text-decoration: line-through; }
|
||
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
|
||
.student__contact-label { color: #526e62; font-size: 22rpx; }
|
||
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }
|
||
.agenda__end { display: block; padding: 12rpx 0 24rpx; text-align: center; color: #81776f; font-size: 21rpx; }
|
||
.empty { padding: 100rpx 48rpx 48rpx; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||
.empty__line { height: 48rpx; width: 1rpx; background: #b4c1b7; margin-bottom: 32rpx; }
|
||
.empty__title { font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; }
|
||
.empty__description { margin-top: 20rpx; font-size: 25rpx; color: #81776f; line-height: 1.7; }
|
||
.outline-button { margin-top: 36rpx; padding: 0 36rpx; min-height: 80rpx; line-height: 80rpx; border: 1rpx solid #bcc7bf; border-radius: 8rpx; color: #526e62; font-size: 25rpx; }
|
||
.skeleton { padding: 12rpx 32rpx; }
|
||
.skeleton__row { display: flex; gap: 24rpx; margin-bottom: 40rpx; }
|
||
.skeleton__time { width: 116rpx; height: 40rpx; background: #eae6df; border-radius: 4rpx; }
|
||
.skeleton__body { flex: 1; height: 180rpx; background: #eeebe5; border-radius: 4rpx; }
|
||
button:active { opacity: .7; }
|
||
</style>
|