feat(app): 新增个人中心课表视图

This commit is contained in:
richarjiang
2026-04-19 22:23:23 +08:00
parent 9575210b06
commit bd3d519b4f
17 changed files with 998 additions and 29 deletions

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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": {

View 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>

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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

View File

@@ -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)
}) })
}) })

View File

@@ -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,

View File

@@ -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],

View File

@@ -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(

View File

@@ -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,
)
})
})
}) })

View File

@@ -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)

View File

@@ -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> {

View File

@@ -50,6 +50,8 @@ export type {
Booking, Booking,
BookingWithDetails, BookingWithDetails,
BookingWithUser, BookingWithUser,
TeachingScheduleStudent,
TeachingScheduleSlot,
BookingStatusHistory, BookingStatusHistory,
CreateBookingDto, CreateBookingDto,
Order, Order,

View File

@@ -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

View File

@@ -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,