feat(app): 新增个人中心课表视图
This commit is contained in:
@@ -82,15 +82,25 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
badge: bookingBadge,
|
badge: bookingBadge,
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
...(props.inviteShareEligible
|
...(props.isAdmin
|
||||||
? [{
|
? [{
|
||||||
key: 'invite',
|
key: 'teaching-schedule',
|
||||||
type: 'item' as const,
|
type: 'item' as const,
|
||||||
title: '邀请好友',
|
title: '我的课表',
|
||||||
path: '/pages/profile/invite',
|
path: '/pages/profile/teaching-schedule',
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}]
|
}]
|
||||||
: []),
|
: []),
|
||||||
|
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
|
||||||
|
// ...(props.inviteShareEligible
|
||||||
|
// ? [{
|
||||||
|
// key: 'invite',
|
||||||
|
// type: 'item' as const,
|
||||||
|
// title: '邀请好友',
|
||||||
|
// path: '/pages/profile/invite',
|
||||||
|
// requireAuth: true,
|
||||||
|
// }]
|
||||||
|
// : []),
|
||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@@ -236,6 +246,29 @@ function handleTap(item: MenuItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--teaching-schedule {
|
||||||
|
background: rgba(93, 140, 138, 0.12);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 24rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border: 2.5rpx solid #476d72;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
transform: translate(-30%, -18%) rotate(45deg);
|
||||||
|
border-top: 2.5rpx solid #476d72;
|
||||||
|
border-left: 2.5rpx solid #476d72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--invite {
|
&--invite {
|
||||||
background: rgba(255, 122, 69, 0.12);
|
background: rgba(255, 122, 69, 0.12);
|
||||||
&::before {
|
&::before {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "普拉提约课",
|
"name": "普拉提约课",
|
||||||
"appid": "",
|
"appid": "wx3e7a133d2305fa2c",
|
||||||
"description": "普拉提工作室约课小程序",
|
"description": "普拉提工作室约课小程序",
|
||||||
"versionName": "0.1.0",
|
"versionName": "0.1.0",
|
||||||
"versionCode": "100",
|
"versionCode": "100",
|
||||||
"transformPx": false,
|
"transformPx": false,
|
||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"appid": "",
|
"appid": "wx3e7a133d2305fa2c",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": false,
|
"urlCheck": false,
|
||||||
"es6": true,
|
"es6": true,
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/teaching-schedule",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/info",
|
"path": "pages/profile/info",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
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>
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BookingWithUser,
|
BookingWithUser,
|
||||||
BookingStatusHistory,
|
BookingStatusHistory,
|
||||||
CreateBookingDto,
|
CreateBookingDto,
|
||||||
|
TeachingScheduleSlot,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { get, post, put } from '../utils/request'
|
import { get, post, put } from '../utils/request'
|
||||||
|
|
||||||
@@ -21,8 +22,10 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
||||||
const myBookings = ref<readonly BookingWithDetails[]>([])
|
const myBookings = ref<readonly BookingWithDetails[]>([])
|
||||||
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
||||||
|
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
|
||||||
const loadingSlots = ref(false)
|
const loadingSlots = ref(false)
|
||||||
const loadingBookings = ref(false)
|
const loadingBookings = ref(false)
|
||||||
|
const loadingTeachingSchedule = ref(false)
|
||||||
|
|
||||||
async function fetchSlots(date: string) {
|
async function fetchSlots(date: string) {
|
||||||
loadingSlots.value = true
|
loadingSlots.value = true
|
||||||
@@ -70,6 +73,21 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTeachingSchedule(date: string) {
|
||||||
|
loadingTeachingSchedule.value = true
|
||||||
|
try {
|
||||||
|
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
|
||||||
|
teachingSchedule.value = Array.isArray(result) ? result : []
|
||||||
|
return teachingSchedule.value
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch teaching schedule failed:', err)
|
||||||
|
teachingSchedule.value = []
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loadingTeachingSchedule.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Admin methods ──────────────────────────────────────────────────────
|
// ─── Admin methods ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchAllAdminBookings(
|
async function fetchAllAdminBookings(
|
||||||
@@ -124,13 +142,16 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
slots,
|
slots,
|
||||||
myBookings,
|
myBookings,
|
||||||
upcomingBookings,
|
upcomingBookings,
|
||||||
|
teachingSchedule,
|
||||||
loadingSlots,
|
loadingSlots,
|
||||||
loadingBookings,
|
loadingBookings,
|
||||||
|
loadingTeachingSchedule,
|
||||||
fetchSlots,
|
fetchSlots,
|
||||||
createBooking,
|
createBooking,
|
||||||
cancelBooking,
|
cancelBooking,
|
||||||
fetchMyBookings,
|
fetchMyBookings,
|
||||||
fetchUpcomingBookings,
|
fetchUpcomingBookings,
|
||||||
|
fetchTeachingSchedule,
|
||||||
fetchAllAdminBookings,
|
fetchAllAdminBookings,
|
||||||
confirmBooking,
|
confirmBooking,
|
||||||
completeBooking,
|
completeBooking,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { get, put } from '../utils/request'
|
|||||||
import { ROUTES } from '../utils/routes'
|
import { ROUTES } from '../utils/routes'
|
||||||
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
||||||
|
|
||||||
|
function syncSubscriptionTemplates(profile?: Pick<UserProfileResponse, 'subscriptionMessageTemplates'> | null) {
|
||||||
|
cacheSubscriptionMessageTemplateConfig(profile?.subscriptionMessageTemplates)
|
||||||
|
}
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// State
|
// State
|
||||||
const user = ref<UserProfileResponse | null>(null)
|
const user = ref<UserProfileResponse | null>(null)
|
||||||
@@ -36,7 +40,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const result = await wxLogin()
|
const result = await wxLogin()
|
||||||
token.value = result.token
|
token.value = result.token
|
||||||
user.value = result.user
|
user.value = result.user
|
||||||
cacheSubscriptionMessageTemplateConfig(result.user.subscriptionMessageTemplates)
|
syncSubscriptionTemplates(result.user)
|
||||||
return { user: result.user, isNewUser: result.isNewUser }
|
return { user: result.user, isNewUser: result.isNewUser }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login failed:', err)
|
console.error('Login failed:', err)
|
||||||
@@ -62,7 +66,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return
|
||||||
try {
|
try {
|
||||||
user.value = await get<UserProfileResponse>('/user/profile')
|
user.value = await get<UserProfileResponse>('/user/profile')
|
||||||
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
syncSubscriptionTemplates(user.value)
|
||||||
return user.value
|
return user.value
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch profile failed:', err)
|
console.error('Fetch profile failed:', err)
|
||||||
@@ -90,13 +94,13 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
||||||
const updated = await put<UserProfileResponse>('/user/profile', data)
|
const updated = await put<UserProfileResponse>('/user/profile', data)
|
||||||
user.value = updated
|
user.value = updated
|
||||||
cacheSubscriptionMessageTemplateConfig(updated.subscriptionMessageTemplates)
|
syncSubscriptionTemplates(updated)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
function setProfile(profile: UserProfileResponse) {
|
function setProfile(profile: UserProfileResponse) {
|
||||||
user.value = profile
|
user.value = profile
|
||||||
cacheSubscriptionMessageTemplateConfig(profile.subscriptionMessageTemplates)
|
syncSubscriptionTemplates(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
|
|||||||
@@ -103,10 +103,18 @@ async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig>
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cacheSubscriptionMessageTemplateConfig(config: SubscriptionMessageTemplateConfig): SubscriptionMessageTemplateConfig {
|
function normalizeTemplateConfig(config?: Partial<SubscriptionMessageTemplateConfig> | null): SubscriptionMessageTemplateConfig {
|
||||||
const normalized: SubscriptionMessageTemplateConfig = {
|
const templates = Array.isArray(config?.templates) ? config.templates : []
|
||||||
templates: config.templates.filter((item) => item.templateId),
|
|
||||||
|
return {
|
||||||
|
templates: templates.filter((item): item is SubscriptionMessageTemplate => !!item?.templateId),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheSubscriptionMessageTemplateConfig(
|
||||||
|
config?: Partial<SubscriptionMessageTemplateConfig> | null,
|
||||||
|
): SubscriptionMessageTemplateConfig {
|
||||||
|
const normalized = normalizeTemplateConfig(config)
|
||||||
cachedConfig = normalized
|
cachedConfig = normalized
|
||||||
uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized)
|
uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized)
|
||||||
return normalized
|
return normalized
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing'
|
import { Test, TestingModule } from '@nestjs/testing'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { UnauthorizedException } from '@nestjs/common'
|
import { UnauthorizedException } from '@nestjs/common'
|
||||||
import { UserRole } from '@mp-pilates/shared'
|
import { MembershipStatus, UserRole } from '@mp-pilates/shared'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||||
import { WechatService } from '../wechat.service'
|
import { WechatService } from '../wechat.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
@@ -23,6 +24,7 @@ const mockUser = {
|
|||||||
nickname: TEST_NICKNAME,
|
nickname: TEST_NICKNAME,
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
role: UserRole.MEMBER,
|
role: UserRole.MEMBER,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
@@ -30,6 +32,9 @@ const mockUser = {
|
|||||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockPrismaService = {
|
const mockPrismaService = {
|
||||||
|
membership: {
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
findUniqueOrThrow: jest.fn(),
|
findUniqueOrThrow: jest.fn(),
|
||||||
@@ -51,6 +56,10 @@ const mockInviteService = {
|
|||||||
bindInviterToUser: jest.fn(),
|
bindInviterToUser: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
@@ -64,6 +73,7 @@ describe('AuthService', () => {
|
|||||||
{ provide: WechatService, useValue: mockWechatService },
|
{ provide: WechatService, useValue: mockWechatService },
|
||||||
{ provide: JwtService, useValue: mockJwtService },
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
{ provide: InviteService, useValue: mockInviteService },
|
{ provide: InviteService, useValue: mockInviteService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
@@ -72,6 +82,8 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||||
|
mockPrismaService.membership.count.mockResolvedValue(0)
|
||||||
|
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── login ──────────────────────────────────────────────────────────────────
|
// ── login ──────────────────────────────────────────────────────────────────
|
||||||
@@ -99,7 +111,17 @@ describe('AuthService', () => {
|
|||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||||
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
||||||
})
|
})
|
||||||
expect(result.user).toEqual(mockUser)
|
expect(result.user).toEqual(expect.objectContaining({
|
||||||
|
id: mockUser.id,
|
||||||
|
phone: mockUser.phone,
|
||||||
|
nickname: mockUser.nickname,
|
||||||
|
avatarUrl: mockUser.avatarUrl,
|
||||||
|
role: mockUser.role,
|
||||||
|
activeMembershipCount: 0,
|
||||||
|
inviteShareEligible: false,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
|
}))
|
||||||
|
expect(result.user.subscriptionMessageTemplates.templates).toHaveLength(2)
|
||||||
expect(result.isNewUser).toBe(true)
|
expect(result.isNewUser).toBe(true)
|
||||||
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
|
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
|
||||||
})
|
})
|
||||||
@@ -139,7 +161,11 @@ describe('AuthService', () => {
|
|||||||
where: { openid: OPENID },
|
where: { openid: OPENID },
|
||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||||
expect(result.user).toEqual(mockUser)
|
expect(result.user).toEqual(expect.objectContaining({
|
||||||
|
id: mockUser.id,
|
||||||
|
nickname: mockUser.nickname,
|
||||||
|
role: mockUser.role,
|
||||||
|
}))
|
||||||
expect(result.isNewUser).toBe(false)
|
expect(result.isNewUser).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -160,11 +186,35 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const result = await authService.login(loginCode)
|
const result = await authService.login(loginCode)
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual(expect.objectContaining({
|
||||||
token: JWT_TOKEN,
|
token: JWT_TOKEN,
|
||||||
user: mockUser,
|
|
||||||
isNewUser: false,
|
isNewUser: false,
|
||||||
|
}))
|
||||||
|
expect(result.user).toEqual(expect.objectContaining({
|
||||||
|
id: mockUser.id,
|
||||||
|
subscriptionMessageTemplates: {
|
||||||
|
templates: [
|
||||||
|
expect.objectContaining({ scene: 'BOOKING_CREATED' }),
|
||||||
|
expect.objectContaining({ scene: 'ADMIN_BOOKING_CREATED' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes active membership count and invite eligibility in login response', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||||
|
mockPrismaService.membership.count.mockResolvedValue(2)
|
||||||
|
|
||||||
|
const result = await authService.login(loginCode)
|
||||||
|
|
||||||
|
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
userId: USER_ID,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
expect(result.user.activeMembershipCount).toBe(2)
|
||||||
|
expect(result.user.inviteShareEligible).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
Controller,
|
||||||
Request,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Request,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
|
import type { UserProfileResponse } from '@mp-pilates/shared'
|
||||||
import { AuthService } from './auth.service'
|
import { AuthService } from './auth.service'
|
||||||
import { LoginDto } from './dto/login.dto'
|
import { LoginDto } from './dto/login.dto'
|
||||||
import { BindPhoneDto } from './dto/bind-phone.dto'
|
import { BindPhoneDto } from './dto/bind-phone.dto'
|
||||||
@@ -24,7 +25,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User; isNewUser: boolean }> {
|
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: UserProfileResponse; isNewUser: boolean }> {
|
||||||
return this.authService.login(
|
return this.authService.login(
|
||||||
loginDto.code,
|
loginDto.code,
|
||||||
loginDto.nickname,
|
loginDto.nickname,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
|
|||||||
import { PassportModule } from '@nestjs/passport'
|
import { PassportModule } from '@nestjs/passport'
|
||||||
import { JwtModule } from '@nestjs/jwt'
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||||
|
import { MembershipModule } from '../membership/membership.module'
|
||||||
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
||||||
import { AuthController } from './auth.controller'
|
import { AuthController } from './auth.controller'
|
||||||
import { WechatService } from './wechat.service'
|
import { WechatService } from './wechat.service'
|
||||||
@@ -14,6 +15,8 @@ import { InviteModule } from '../invite/invite.module'
|
|||||||
imports: [
|
imports: [
|
||||||
PassportModule,
|
PassportModule,
|
||||||
InviteModule,
|
InviteModule,
|
||||||
|
ConfigModule,
|
||||||
|
MembershipModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { User } from '@prisma/client'
|
import { User } from '@prisma/client'
|
||||||
import { UserRole } from '@mp-pilates/shared'
|
import {
|
||||||
|
MembershipStatus,
|
||||||
|
SubscriptionMessageScene,
|
||||||
|
type SubscriptionMessageTemplate,
|
||||||
|
type SubscriptionMessageTemplateConfig,
|
||||||
|
type UserProfileResponse,
|
||||||
|
UserRole,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { WechatService } from './wechat.service'
|
import { WechatService } from './wechat.service'
|
||||||
import { InviteService } from '../invite/invite.service'
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string
|
token: string
|
||||||
user: User
|
user: UserProfileResponse
|
||||||
isNewUser: boolean
|
isNewUser: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +65,53 @@ export class AuthService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly wechatService: WechatService,
|
private readonly wechatService: WechatService,
|
||||||
private readonly inviteService: InviteService,
|
private readonly inviteService: InviteService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||||
|
const templates = [
|
||||||
|
{
|
||||||
|
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||||
|
usageTarget: 'consent' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||||
|
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||||
|
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
|
||||||
|
usageTarget: 'counter' as const,
|
||||||
|
},
|
||||||
|
] satisfies SubscriptionMessageTemplate[]
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates: templates.filter((item) => item.templateId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mapLoginUser(user: User): Promise<UserProfileResponse> {
|
||||||
|
const activeMembershipCount = await this.prisma.membership.count({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role as UserRole,
|
||||||
|
activeMembershipCount,
|
||||||
|
inviteShareEligible: activeMembershipCount > 0,
|
||||||
|
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
||||||
|
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async login(
|
async login(
|
||||||
code: string,
|
code: string,
|
||||||
nickname?: string,
|
nickname?: string,
|
||||||
@@ -96,7 +148,7 @@ export class AuthService {
|
|||||||
sessionKeyStore.set(updated.id, sessionKey)
|
sessionKeyStore.set(updated.id, sessionKey)
|
||||||
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
||||||
const token = this.jwtService.sign(payload)
|
const token = this.jwtService.sign(payload)
|
||||||
return { token, user: updated, isNewUser: false }
|
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionKeyStore.set(user.id, sessionKey)
|
sessionKeyStore.set(user.id, sessionKey)
|
||||||
@@ -108,7 +160,7 @@ export class AuthService {
|
|||||||
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||||
const token = this.jwtService.sign(payload)
|
const token = this.jwtService.sign(payload)
|
||||||
|
|
||||||
return { token, user, isNewUser }
|
return { token, user: await this.mapLoginUser(user), isNewUser }
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindPhone(
|
async bindPhone(
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ describe('BookingService', () => {
|
|||||||
},
|
},
|
||||||
timeSlot: {
|
timeSlot: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
membership: {
|
membership: {
|
||||||
@@ -903,4 +904,101 @@ describe('BookingService', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getTeachingScheduleByDate', () => {
|
||||||
|
it('returns sorted slots with active students only', async () => {
|
||||||
|
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'slot-02',
|
||||||
|
startTime: '11:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
bookedCount: 1,
|
||||||
|
capacity: 1,
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
id: 'booking-02',
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
createdAt: new Date('2026-04-19T01:00:00Z'),
|
||||||
|
user: { id: 'user-02', nickname: '李四', phone: '13800000000' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slot-01',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '10:00',
|
||||||
|
bookedCount: 2,
|
||||||
|
capacity: 2,
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
id: 'booking-01',
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
createdAt: new Date('2026-04-19T00:00:00Z'),
|
||||||
|
user: { id: 'user-01', nickname: '张三', phone: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await service.getTeachingScheduleByDate('2026-04-19')
|
||||||
|
|
||||||
|
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
bookings: {
|
||||||
|
some: {
|
||||||
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
orderBy: [
|
||||||
|
{ startTime: 'asc' },
|
||||||
|
{ endTime: 'asc' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
slotId: 'slot-01',
|
||||||
|
date: '2026-04-19',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '10:00',
|
||||||
|
bookedCount: 2,
|
||||||
|
capacity: 2,
|
||||||
|
students: [
|
||||||
|
{
|
||||||
|
bookingId: 'booking-01',
|
||||||
|
userId: 'user-01',
|
||||||
|
nickname: '张三',
|
||||||
|
phone: null,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slotId: 'slot-02',
|
||||||
|
date: '2026-04-19',
|
||||||
|
startTime: '11:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
bookedCount: 1,
|
||||||
|
capacity: 1,
|
||||||
|
students: [
|
||||||
|
{
|
||||||
|
bookingId: 'booking-02',
|
||||||
|
userId: 'user-02',
|
||||||
|
nickname: '李四',
|
||||||
|
phone: '13800000000',
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid date input', async () => {
|
||||||
|
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -91,6 +92,16 @@ export class BookingController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('admin/teaching-schedule')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async getTeachingSchedule(@Query('date') date?: string) {
|
||||||
|
if (!date) {
|
||||||
|
throw new BadRequestException('date is required')
|
||||||
|
}
|
||||||
|
return this.bookingService.getTeachingScheduleByDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
@Put('booking/:id/confirm')
|
@Put('booking/:id/confirm')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
import {
|
||||||
|
BookingStatus,
|
||||||
|
CardTypeCategory,
|
||||||
|
MembershipStatus,
|
||||||
|
TimeSlotStatus,
|
||||||
|
type TeachingScheduleSlot,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { MembershipService } from '../membership/membership.service'
|
import { MembershipService } from '../membership/membership.service'
|
||||||
import { StudioService } from '../studio/studio.service'
|
import { StudioService } from '../studio/studio.service'
|
||||||
@@ -582,6 +588,72 @@ export class BookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTeachingScheduleByDate(date: string): Promise<TeachingScheduleSlot[]> {
|
||||||
|
const dayStart = new Date(`${date}T00:00:00.000Z`)
|
||||||
|
if (Number.isNaN(dayStart.getTime())) {
|
||||||
|
throw new BadRequestException('Invalid date')
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = await this.prisma.timeSlot.findMany({
|
||||||
|
where: {
|
||||||
|
date: dayStart,
|
||||||
|
bookings: {
|
||||||
|
some: {
|
||||||
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nickname: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ status: 'asc' },
|
||||||
|
{ createdAt: 'asc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ startTime: 'asc' },
|
||||||
|
{ endTime: 'asc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return slots
|
||||||
|
.map((slot) => ({
|
||||||
|
slotId: slot.id,
|
||||||
|
date,
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
bookedCount: slot.bookedCount,
|
||||||
|
capacity: slot.capacity,
|
||||||
|
students: slot.bookings.map((booking) => ({
|
||||||
|
bookingId: booking.id,
|
||||||
|
userId: booking.user.id,
|
||||||
|
nickname: booking.user.nickname,
|
||||||
|
phone: booking.user.phone,
|
||||||
|
status: booking.status as BookingStatus,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const byStart = a.startTime.localeCompare(b.startTime)
|
||||||
|
if (byStart !== 0) {
|
||||||
|
return byStart
|
||||||
|
}
|
||||||
|
return a.endTime.localeCompare(b.endTime)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Private Helpers ─────────────────────────────────────────────────────
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type {
|
|||||||
Booking,
|
Booking,
|
||||||
BookingWithDetails,
|
BookingWithDetails,
|
||||||
BookingWithUser,
|
BookingWithUser,
|
||||||
|
TeachingScheduleStudent,
|
||||||
|
TeachingScheduleSlot,
|
||||||
BookingStatusHistory,
|
BookingStatusHistory,
|
||||||
CreateBookingDto,
|
CreateBookingDto,
|
||||||
Order,
|
Order,
|
||||||
|
|||||||
@@ -37,6 +37,24 @@ export interface BookingWithUser extends BookingWithDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeachingScheduleStudent {
|
||||||
|
readonly bookingId: string
|
||||||
|
readonly userId: string
|
||||||
|
readonly nickname: string
|
||||||
|
readonly phone: string | null
|
||||||
|
readonly status: BookingStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeachingScheduleSlot {
|
||||||
|
readonly slotId: string
|
||||||
|
readonly date: string
|
||||||
|
readonly startTime: string
|
||||||
|
readonly endTime: string
|
||||||
|
readonly bookedCount: number
|
||||||
|
readonly capacity: number
|
||||||
|
readonly students: readonly TeachingScheduleStudent[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookingStatusHistory {
|
export interface BookingStatusHistory {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly bookingId: string
|
readonly bookingId: string
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type
|
|||||||
export type { Membership, MembershipWithCardType } from './membership'
|
export type { Membership, MembershipWithCardType } from './membership'
|
||||||
export type { WeekTemplate, WeekTemplateInput } from './week-template'
|
export type { WeekTemplate, WeekTemplateInput } from './week-template'
|
||||||
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
|
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
|
||||||
export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory, CreateBookingDto } from './booking'
|
export type {
|
||||||
|
Booking,
|
||||||
|
BookingWithDetails,
|
||||||
|
BookingWithUser,
|
||||||
|
TeachingScheduleStudent,
|
||||||
|
TeachingScheduleSlot,
|
||||||
|
BookingStatusHistory,
|
||||||
|
CreateBookingDto,
|
||||||
|
} from './booking'
|
||||||
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
|
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
|
||||||
export type {
|
export type {
|
||||||
StudioConfig,
|
StudioConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user