feat: 支持管理员消息推送
This commit is contained in:
@@ -47,6 +47,7 @@ interface MenuItem {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
|
activeMembershipCount?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -56,12 +57,17 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
|
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
|
||||||
|
? `${props.activeMembershipCount}张`
|
||||||
|
: undefined
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{
|
{
|
||||||
key: 'membership',
|
key: 'membership',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
title: '我的会员卡',
|
title: '我的会员卡',
|
||||||
path: '/pages/profile/membership',
|
path: '/pages/profile/membership',
|
||||||
|
badge: membershipBadge,
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -346,11 +352,17 @@ function handleTap(item: MenuItem) {
|
|||||||
|
|
||||||
&__badge {
|
&__badge {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #ffffff;
|
line-height: 1;
|
||||||
background: $error-color;
|
font-weight: 600;
|
||||||
border-radius: 20rpx;
|
color: #496578;
|
||||||
padding: 2rpx 12rpx;
|
background: linear-gradient(135deg, rgba(239, 247, 251, 0.98), rgba(218, 234, 243, 0.96));
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 9rpx 18rpx;
|
||||||
margin-right: $spacing-sm;
|
margin-right: $spacing-sm;
|
||||||
|
border: 1rpx solid rgba(123, 165, 190, 0.18);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1rpx 0 rgba(255, 255, 255, 0.92),
|
||||||
|
0 6rpx 16rpx rgba(123, 165, 190, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__arrow {
|
&__arrow {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
<!-- OPEN + booked by me -->
|
<!-- OPEN + booked by me -->
|
||||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
||||||
<view class="action-badge badge-booked">
|
<view class="action-badge badge-booked">
|
||||||
<text>已预约</text>
|
<text>{{ myBookingLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||||
<text>取消</text>
|
<text>取消</text>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
||||||
import { TimeSlotStatus } from '@mp-pilates/shared'
|
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { isSlotPast } from '../utils/format'
|
import { isSlotPast } from '../utils/format'
|
||||||
|
|
||||||
@@ -120,6 +120,12 @@ const durationMin = computed(() => {
|
|||||||
return (eh * 60 + em) - (sh * 60 + sm)
|
return (eh * 60 + em) - (sh * 60 + sm)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const myBookingLabel = computed(() => (
|
||||||
|
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
||||||
|
? '已预约待确认'
|
||||||
|
: '已预约'
|
||||||
|
))
|
||||||
|
|
||||||
const capacityLabel = computed(() => {
|
const capacityLabel = computed(() => {
|
||||||
const { bookedCount, capacity, status } = props.timeSlot
|
const { bookedCount, capacity, status } = props.timeSlot
|
||||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||||
|
|||||||
@@ -1,40 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="studio-info">
|
<view class="studio-info">
|
||||||
<!-- Horizontal photo strip -->
|
|
||||||
<scroll-view v-if="studioInfo?.photos?.length" scroll-x class="photo-strip" :show-scrollbar="false">
|
|
||||||
<view class="photo-strip-inner">
|
|
||||||
<image v-for="(photo, idx) in studioInfo.photos" :key="idx" class="strip-photo" :src="photo" mode="aspectFill"
|
|
||||||
@tap="previewPhoto(idx)" />
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
|
|
||||||
<!-- Address + Chat row -->
|
<!-- Address + Chat row -->
|
||||||
<view class="location-row">
|
<view class="location-row">
|
||||||
<view class="location-left" @tap="handleAddressTap">
|
<view class="location-left" @tap="handleAddressTap">
|
||||||
<view class="location-icon" />
|
<view class="location-icon" />
|
||||||
|
<view class="location-content">
|
||||||
|
<text class="location-label">场馆地址</text>
|
||||||
<text class="location-text">
|
<text class="location-text">
|
||||||
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
<button class="chat-btn" open-type="contact">
|
<button class="chat-btn" open-type="contact">
|
||||||
<view class="chat-icon" />
|
<view class="chat-icon" />
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Horizontal gallery -->
|
||||||
|
<view class="gallery-block">
|
||||||
|
<scroll-view scroll-x class="gallery-scroll" :show-scrollbar="false" enhanced>
|
||||||
|
<view class="gallery-track">
|
||||||
|
<view
|
||||||
|
v-for="(photo, idx) in galleryPhotos"
|
||||||
|
:key="photo"
|
||||||
|
class="gallery-item"
|
||||||
|
@tap="previewPhoto(idx)"
|
||||||
|
>
|
||||||
|
<image class="gallery-image" :src="photo" mode="aspectFill" />
|
||||||
|
<view class="gallery-overlay" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import type { StudioConfig } from '@mp-pilates/shared'
|
import type { StudioConfig } from '@mp-pilates/shared'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
studioInfo: StudioConfig | null
|
studioInfo: StudioConfig | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const defaultGalleryPhotos = [
|
||||||
|
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_1.jpg',
|
||||||
|
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_2.jpg',
|
||||||
|
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_3.jpg',
|
||||||
|
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_4.jpg',
|
||||||
|
]
|
||||||
|
|
||||||
|
const galleryPhotos = computed(() => {
|
||||||
|
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
|
||||||
|
return photos.length ? photos : defaultGalleryPhotos
|
||||||
|
})
|
||||||
|
|
||||||
function previewPhoto(index: number) {
|
function previewPhoto(index: number) {
|
||||||
if (!props.studioInfo?.photos?.length) return
|
|
||||||
uni.previewImage({
|
uni.previewImage({
|
||||||
current: index,
|
current: index,
|
||||||
urls: props.studioInfo.photos,
|
urls: galleryPhotos.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,33 +100,12 @@ function copyAddress() {
|
|||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Photo strip ── */
|
|
||||||
.photo-strip {
|
|
||||||
width: 100%;
|
|
||||||
padding: 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-strip-inner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding: 0 24rpx;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strip-photo {
|
|
||||||
width: 240rpx;
|
|
||||||
height: 160rpx;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Location row ── */
|
/* ── Location row ── */
|
||||||
.location-row {
|
.location-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 24rpx 32rpx 28rpx;
|
padding: 28rpx 32rpx 24rpx;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,11 +117,64 @@ function copyAddress() {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #b39a92;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.location-text {
|
.location-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #666;
|
color: #5f5955;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gallery ── */
|
||||||
|
.gallery-block {
|
||||||
|
padding: 6rpx 0 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-scroll {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
position: relative;
|
||||||
|
width: 192rpx;
|
||||||
|
height: 108rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #eadfd8 0%, #d5c0b4 100%);
|
||||||
|
box-shadow: 0 8rpx 18rpx rgba(124, 95, 82, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(38, 28, 24, 0.2) 100%),
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, transparent 48%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Icons ── */
|
/* ── Icons ── */
|
||||||
|
|||||||
@@ -25,14 +25,16 @@
|
|||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
@error="onAvatarError"
|
@error="onAvatarError"
|
||||||
/>
|
/>
|
||||||
<!-- VIP badge hidden for now -->
|
|
||||||
<!-- <view class="user-card__vip-badge" v-if="vipLevel">
|
|
||||||
<text class="user-card__vip-text">{{ vipLevel }}</text>
|
|
||||||
</view> -->
|
|
||||||
</view>
|
</view>
|
||||||
<view class="user-card__info">
|
<view class="user-card__info">
|
||||||
<view class="user-card__name-row">
|
<view class="user-card__name-row">
|
||||||
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
||||||
|
<view v-if="hasMembership" class="user-card__member-badge">
|
||||||
|
<view class="user-card__member-icon">
|
||||||
|
<text class="user-card__member-letter">C</text>
|
||||||
|
</view>
|
||||||
|
<text class="user-card__member-label">CLUB</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -75,7 +77,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { MembershipStatus } from '@mp-pilates/shared'
|
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
loggedIn: boolean
|
loggedIn: boolean
|
||||||
@@ -117,24 +119,25 @@ const maskedPhone = computed(() => {
|
|||||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Derive VIP level from active memberships count
|
|
||||||
const activeMemberships = computed(() =>
|
const activeMemberships = computed(() =>
|
||||||
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
||||||
)
|
)
|
||||||
|
|
||||||
const vipLevel = computed(() => {
|
const activeMembershipCount = computed(
|
||||||
const count = activeMemberships.value.length
|
() => props.user?.activeMembershipCount ?? activeMemberships.value.length,
|
||||||
if (count >= 3) return 'VIP3'
|
)
|
||||||
if (count >= 2) return 'VIP2'
|
|
||||||
if (count >= 1) return 'VIP1'
|
const hasMembership = computed(() => activeMembershipCount.value > 0)
|
||||||
return null
|
|
||||||
})
|
function toSafeCount(value: number | null | undefined): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
// Sum remaining sessions from all active time-based memberships
|
// Sum remaining sessions from all active time-based memberships
|
||||||
const remainingSessions = computed(() =>
|
const remainingSessions = computed(() =>
|
||||||
activeMemberships.value
|
activeMemberships.value
|
||||||
.filter((m) => m.cardType.type === 'TIMES')
|
.filter((m) => m.cardType.type === CardTypeCategory.TIMES)
|
||||||
.reduce((sum, m) => sum + m.remainingCount, 0),
|
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
function onAvatarError() {
|
function onAvatarError() {
|
||||||
@@ -220,24 +223,6 @@ function handleLogin() {
|
|||||||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__vip-badge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: -6rpx;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 2rpx 12rpx;
|
|
||||||
border: 2rpx solid #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__vip-text {
|
|
||||||
font-size: 18rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #7c2d12;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -257,6 +242,59 @@ function handleLogin() {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__member-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
padding: 6rpx 14rpx 6rpx 8rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(244, 250, 253, 0.98), rgba(219, 235, 243, 0.94));
|
||||||
|
border: 1rpx solid rgba(123, 165, 190, 0.22);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1rpx 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 8rpx 20rpx rgba(79, 123, 148, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__member-icon {
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: radial-gradient(circle at 30% 30%, #ffffff 0%, #dfeef6 38%, #8fb6cb 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.76),
|
||||||
|
0 3rpx 8rpx rgba(77, 117, 140, 0.18);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 3rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5rpx solid rgba(92, 132, 156, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__member-letter {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #4f6f82;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__member-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #537488;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
&__phone {
|
&__phone {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
|||||||
@@ -169,6 +169,21 @@
|
|||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--subscribe">
|
||||||
|
<text class="item-icon-text">✦</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-text-group">
|
||||||
|
<text class="item-title">增加订阅次数</text>
|
||||||
|
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-arrow">
|
||||||
|
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '›' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view style="height: 40rpx" />
|
<view style="height: 40rpx" />
|
||||||
@@ -177,17 +192,24 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
import type { AdminStats } from '../../stores/admin'
|
import type { AdminStats } from '../../stores/admin'
|
||||||
|
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { user } = storeToRefs(userStore)
|
||||||
|
|
||||||
const statsLoading = ref(false)
|
const statsLoading = ref(false)
|
||||||
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
||||||
|
const adminSubscribeLoading = ref(false)
|
||||||
|
|
||||||
function navigate(path: string) {
|
function navigate(path: string) {
|
||||||
uni.navigateTo({ url: path })
|
uni.navigateTo({ url: path })
|
||||||
@@ -204,9 +226,32 @@ async function loadStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleIncreaseSubscriptionCount() {
|
||||||
|
if (adminSubscribeLoading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminSubscribeLoading.value = true
|
||||||
|
try {
|
||||||
|
const profile = await requestAdminBookingSubscriptionCount()
|
||||||
|
if (!profile) {
|
||||||
|
uni.showToast({ title: '已取消本次授权', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userStore.setProfile(profile)
|
||||||
|
uni.showToast({ title: `订阅次数 +1,剩余 ${profile.adminBookingSubscriptionCount}`, icon: 'none' })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '订阅授权失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
adminSubscribeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
loadStats()
|
loadStats()
|
||||||
|
userStore.fetchProfile()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -347,6 +392,7 @@ onMounted(() => {
|
|||||||
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
|
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
|
||||||
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
|
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
|
||||||
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
|
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
|
||||||
|
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
|
||||||
|
|
||||||
.item-text-group {
|
.item-text-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -207,6 +207,11 @@
|
|||||||
<text class="action-btn-text">立即预约</text>
|
<text class="action-btn-text">立即预约</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-else-if="slotData.isBookedByMe && slotData.myBookingId" class="action-bar">
|
||||||
|
<view class="action-btn action-btn--disabled" @tap="goToMyBookingDetail">
|
||||||
|
<text class="action-btn-text">{{ slotActionLabel }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<view v-else-if="slotData.status === TimeSlotStatus.FULL" class="action-bar">
|
<view v-else-if="slotData.status === TimeSlotStatus.FULL" class="action-bar">
|
||||||
<view class="action-btn action-btn--disabled">
|
<view class="action-btn action-btn--disabled">
|
||||||
<text class="action-btn-text">已约满</text>
|
<text class="action-btn-text">已约满</text>
|
||||||
@@ -293,6 +298,14 @@ const canBook = computed(() => {
|
|||||||
return !isSlotPast(slotData.value.date, slotData.value.startTime)
|
return !isSlotPast(slotData.value.date, slotData.value.startTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const slotActionLabel = computed(() => {
|
||||||
|
if (slotData.value?.myBookingStatus === BookingStatus.PENDING_CONFIRMATION) {
|
||||||
|
return '已预约待确认'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '已预约'
|
||||||
|
})
|
||||||
|
|
||||||
const slotCapacityClass = computed(() => {
|
const slotCapacityClass = computed(() => {
|
||||||
if (!slotData.value) return ''
|
if (!slotData.value) return ''
|
||||||
const { bookedCount, capacity } = slotData.value
|
const { bookedCount, capacity } = slotData.value
|
||||||
@@ -346,6 +359,11 @@ async function loadSlotData() {
|
|||||||
|
|
||||||
// ─── Slot mode: Booking flow ─────────────────────────────────────────────
|
// ─── Slot mode: Booking flow ─────────────────────────────────────────────
|
||||||
async function handleSlotBook() {
|
async function handleSlotBook() {
|
||||||
|
if (slotData.value?.isBookedByMe) {
|
||||||
|
goToMyBookingDetail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!userStore.loggedIn) {
|
if (!userStore.loggedIn) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
@@ -386,6 +404,11 @@ async function handleSlotBook() {
|
|||||||
showConfirmPopup.value = true
|
showConfirmPopup.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToMyBookingDetail() {
|
||||||
|
if (!slotData.value?.myBookingId) return
|
||||||
|
uni.redirectTo({ url: `/pages/booking/detail?id=${slotData.value.myBookingId}` })
|
||||||
|
}
|
||||||
|
|
||||||
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
|
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
|
||||||
showConfirmPopup.value = false
|
showConfirmPopup.value = false
|
||||||
uni.showLoading({ title: '预约中...' })
|
uni.showLoading({ title: '预约中...' })
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
@@ -196,6 +196,19 @@ function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
|
|
||||||
// ─── Book flow ────────────────────────────────────────────
|
// ─── Book flow ────────────────────────────────────────────
|
||||||
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
||||||
|
if (slot.isBookedByMe) {
|
||||||
|
if (slot.myBookingId) {
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = slot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
||||||
|
? '该时段已预约,等待老师确认'
|
||||||
|
: '该时段已预约'
|
||||||
|
uni.showToast({ title, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Ensure logged in
|
// 1. Ensure logged in
|
||||||
if (!userStore.loggedIn) {
|
if (!userStore.loggedIn) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ async function refreshData() {
|
|||||||
|
|
||||||
if (userStore.loggedIn) {
|
if (userStore.loggedIn) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
|
userStore.fetchProfile(),
|
||||||
userStore.fetchMemberships(),
|
userStore.fetchMemberships(),
|
||||||
bookingStore.fetchUpcomingBookings(),
|
bookingStore.fetchUpcomingBookings(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
||||||
|
|
||||||
<!-- Menu section: always visible -->
|
<!-- Menu section: always visible -->
|
||||||
<ProfileMenu :is-admin="isAdmin" :require-auth="loggedIn" @clear-cache="handleClearCache" @about="handleAbout"
|
<ProfileMenu
|
||||||
@require-login="handleLogin" />
|
:is-admin="isAdmin"
|
||||||
|
:require-auth="loggedIn"
|
||||||
|
:active-membership-count="activeMembershipCount"
|
||||||
|
@clear-cache="handleClearCache"
|
||||||
|
@about="handleAbout"
|
||||||
|
@require-login="handleLogin"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Logout button: only when logged in -->
|
<!-- Logout button: only when logged in -->
|
||||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||||
@@ -19,7 +25,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
@@ -35,6 +41,10 @@ const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(
|
|||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
const navBarHeight = ref(64)
|
const navBarHeight = ref(64)
|
||||||
|
|
||||||
|
const activeMembershipCount = computed(
|
||||||
|
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||||
|
)
|
||||||
|
|
||||||
// ─── 微信分享 ───────────────────────────────────────────────
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,6 +35,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)
|
||||||
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)
|
||||||
@@ -61,6 +62,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
try {
|
try {
|
||||||
user.value = await get<UserProfileResponse>('/user/profile')
|
user.value = await get<UserProfileResponse>('/user/profile')
|
||||||
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
||||||
|
return user.value
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch profile failed:', err)
|
console.error('Fetch profile failed:', err)
|
||||||
}
|
}
|
||||||
@@ -87,9 +89,15 @@ 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)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setProfile(profile: UserProfileResponse) {
|
||||||
|
user.value = profile
|
||||||
|
cacheSubscriptionMessageTemplateConfig(profile.subscriptionMessageTemplates)
|
||||||
|
}
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
fetchProfile()
|
fetchProfile()
|
||||||
@@ -122,6 +130,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
fetchStats,
|
fetchStats,
|
||||||
fetchMemberships,
|
fetchMemberships,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
setProfile,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
SubscriptionMessageRequestItem,
|
SubscriptionMessageRequestItem,
|
||||||
SubscriptionMessageTemplate,
|
SubscriptionMessageTemplate,
|
||||||
SubscriptionMessageTemplateConfig,
|
SubscriptionMessageTemplateConfig,
|
||||||
|
UserProfileResponse,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { post } from './request'
|
import { post } from './request'
|
||||||
|
|
||||||
@@ -30,16 +31,20 @@ function stringifyDebugPayload(payload: unknown): string {
|
|||||||
|
|
||||||
function getSubscribeDebugContext() {
|
function getSubscribeDebugContext() {
|
||||||
try {
|
try {
|
||||||
const systemInfo = uni.getSystemInfoSync()
|
const windowInfo = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||||
const host = systemInfo.host as { env?: string } | string | undefined
|
const appBaseInfo = typeof uni.getAppBaseInfo === 'function' ? uni.getAppBaseInfo() : null
|
||||||
|
const deviceInfo = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||||
|
const host = appBaseInfo?.host as { env?: string } | string | undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platform: systemInfo.platform,
|
platform: deviceInfo?.platform ?? 'unknown',
|
||||||
hostEnv: typeof host === 'object' && host ? host.env : undefined,
|
hostEnv: typeof host === 'object' && host ? host.env : undefined,
|
||||||
app: systemInfo.appName,
|
app: appBaseInfo?.appName,
|
||||||
system: systemInfo.system,
|
system: deviceInfo?.system,
|
||||||
language: systemInfo.language,
|
language: appBaseInfo?.language,
|
||||||
version: systemInfo.version,
|
version: appBaseInfo?.version,
|
||||||
SDKVersion: systemInfo.SDKVersion,
|
SDKVersion: appBaseInfo?.SDKVersion,
|
||||||
|
windowWidth: windowInfo?.windowWidth,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
@@ -182,6 +187,61 @@ export async function requestBookingCreatedSubscriptionMessage(): Promise<Subscr
|
|||||||
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
|
||||||
|
if (!isMpWeixin()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await fetchTemplateConfig()
|
||||||
|
const templates = getTemplatesByScene(config, SubscriptionMessageScene.ADMIN_BOOKING_CREATED)
|
||||||
|
.filter((item) => item.usageTarget === 'counter')
|
||||||
|
|
||||||
|
if (templates.length === 0) {
|
||||||
|
console.error('[subscribe] no admin counter template configured', stringifyDebugPayload({
|
||||||
|
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||||
|
config,
|
||||||
|
debugContext: getSubscribeDebugContext(),
|
||||||
|
}))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = templates[0].templateId
|
||||||
|
const debugContext = getSubscribeDebugContext()
|
||||||
|
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:adminCounter:start', stringifyDebugPayload({
|
||||||
|
templateId,
|
||||||
|
debugContext,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
|
||||||
|
uni.requestSubscribeMessage({
|
||||||
|
tmplIds: [templateId],
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:adminCounter:success', stringifyDebugPayload({ response: res, templateId, debugContext }))
|
||||||
|
resolve(res as RequestSubscribeMessageSuccess)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[subscribe] requestSubscribeMessage:adminCounter:fail', stringifyDebugPayload({ error: err, templateId, debugContext }))
|
||||||
|
reject(buildSubscribeError(err as RequestSubscribeMessageFail, SubscriptionMessageScene.ADMIN_BOOKING_CREATED, [templateId]))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = normalizeResult(result[templateId])
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:adminCounter:normalized', stringifyDebugPayload({
|
||||||
|
templateId,
|
||||||
|
normalized,
|
||||||
|
rawResult: result,
|
||||||
|
debugContext,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (normalized !== 'accept') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return post<UserProfileResponse>('/user/subscription-messages/admin-booking-count')
|
||||||
|
}
|
||||||
|
|
||||||
export function resetSubscriptionMessageTemplateCache(): void {
|
export function resetSubscriptionMessageTemplateCache(): void {
|
||||||
cachedConfig = null
|
cachedConfig = null
|
||||||
uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY)
|
uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ model User {
|
|||||||
nickname String @default("")
|
nickname String @default("")
|
||||||
avatarUrl String? @map("avatar_url")
|
avatarUrl String? @map("avatar_url")
|
||||||
role UserRole @default(MEMBER)
|
role UserRole @default(MEMBER)
|
||||||
|
adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ async function main() {
|
|||||||
openid: 'admin_test_openid',
|
openid: 'admin_test_openid',
|
||||||
nickname: '教练',
|
nickname: '教练',
|
||||||
role: UserRole.ADMIN,
|
role: UserRole.ADMIN,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(' ✅ Admin user created')
|
console.log(' ✅ Admin user created')
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export class AuthService {
|
|||||||
...(unionid !== undefined && { unionid }),
|
...(unionid !== undefined && { unionid }),
|
||||||
nickname: nickname || generateDefaultNickname(this.randomFn),
|
nickname: nickname || generateDefaultNickname(this.randomFn),
|
||||||
...(avatarUrl && { avatarUrl }),
|
...(avatarUrl && { avatarUrl }),
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus, UserRole } from '@mp-pilates/shared'
|
||||||
import { BookingService } from '../booking.service'
|
import { BookingService } from '../booking.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
import { MembershipService } from '../../membership/membership.service'
|
import { MembershipService } from '../../membership/membership.service'
|
||||||
@@ -152,7 +152,7 @@ describe('BookingService', () => {
|
|||||||
let service: BookingService
|
let service: BookingService
|
||||||
let prisma: jest.Mocked<PrismaService>
|
let prisma: jest.Mocked<PrismaService>
|
||||||
let studioService: jest.Mocked<StudioService>
|
let studioService: jest.Mocked<StudioService>
|
||||||
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock }
|
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -179,6 +179,7 @@ describe('BookingService', () => {
|
|||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -200,6 +201,7 @@ describe('BookingService', () => {
|
|||||||
provide: SubscriptionMessageService,
|
provide: SubscriptionMessageService,
|
||||||
useValue: {
|
useValue: {
|
||||||
sendBookingConfirmedMessage: jest.fn(),
|
sendBookingConfirmedMessage: jest.fn(),
|
||||||
|
sendAdminBookingCreatedMessage: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -284,6 +286,7 @@ describe('BookingService', () => {
|
|||||||
timeSlot: mockOpenSlot,
|
timeSlot: mockOpenSlot,
|
||||||
membership: mockActiveMembership,
|
membership: mockActiveMembership,
|
||||||
})
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
const result = await service.createBooking(MOCK_USER_ID, dto)
|
const result = await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
@@ -323,6 +326,7 @@ describe('BookingService', () => {
|
|||||||
timeSlot: nearFullSlot,
|
timeSlot: nearFullSlot,
|
||||||
membership: mockActiveMembership,
|
membership: mockActiveMembership,
|
||||||
})
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
await service.createBooking(MOCK_USER_ID, dto)
|
await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
@@ -353,6 +357,7 @@ describe('BookingService', () => {
|
|||||||
timeSlot: mockOpenSlot,
|
timeSlot: mockOpenSlot,
|
||||||
membership: mockDurationMembership,
|
membership: mockDurationMembership,
|
||||||
})
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
await service.createBooking(MOCK_USER_ID, durationDto)
|
await service.createBooking(MOCK_USER_ID, durationDto)
|
||||||
|
|
||||||
@@ -379,12 +384,61 @@ describe('BookingService', () => {
|
|||||||
timeSlot: mockOpenSlot,
|
timeSlot: mockOpenSlot,
|
||||||
membership: mockMembershipNoTimes,
|
membership: mockMembershipNoTimes,
|
||||||
})
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
await service.createBooking(MOCK_USER_ID, dto)
|
await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends admin booking created subscription message to admins with remaining count', async () => {
|
||||||
|
const tx = buildTxMock()
|
||||||
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
|
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||||
|
tx.booking.create.mockResolvedValue({
|
||||||
|
...mockConfirmedBooking,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
})
|
||||||
|
|
||||||
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
|
...mockConfirmedBooking,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
timeSlot: mockOpenSlot,
|
||||||
|
membership: mockActiveMembership,
|
||||||
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ openid: 'admin-openid-1' },
|
||||||
|
])
|
||||||
|
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ nickname: 'Alice', phone: '13800000000' })
|
||||||
|
studioService.getInfo.mockResolvedValue({
|
||||||
|
...mockStudioConfig,
|
||||||
|
name: 'FocusCore Pilates',
|
||||||
|
})
|
||||||
|
subscriptionMessageService.sendAdminBookingCreatedMessage.mockResolvedValue(true)
|
||||||
|
|
||||||
|
await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
|
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
adminBookingSubscriptionCount: { gt: 0 },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
openid: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).toHaveBeenCalledWith({
|
||||||
|
openid: 'admin-openid-1',
|
||||||
|
bookingId: MOCK_BOOKING_ID,
|
||||||
|
bookingContent: 'Alice已预约',
|
||||||
|
bookingTime: '2099-12-31 09:00',
|
||||||
|
courseName: 'FocusCore Pilates',
|
||||||
|
bookingPeriod: '2099-12-31 09:00~10:00',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('throws BadRequestException when slot is FULL', async () => {
|
it('throws BadRequestException when slot is FULL', async () => {
|
||||||
const fullDto = { timeSlotId: mockFullSlot.id, membershipId: MOCK_MEMBERSHIP_ID }
|
const fullDto = { timeSlotId: mockFullSlot.id, membershipId: MOCK_MEMBERSHIP_ID }
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ export class BookingService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Re-fetch with relations after transaction
|
// Re-fetch with relations after transaction
|
||||||
return this.fetchBookingWithRelations(booking.id)
|
const bookingWithRelations = await this.fetchBookingWithRelations(booking.id)
|
||||||
|
await this.trySendAdminBookingCreatedSubscriptionMessages(bookingWithRelations)
|
||||||
|
return bookingWithRelations
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
|
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
|
||||||
@@ -600,4 +602,63 @@ export class BookingService {
|
|||||||
console.error('Send booking confirmed subscription message failed:', error)
|
console.error('Send booking confirmed subscription message failed:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async trySendAdminBookingCreatedSubscriptionMessages(
|
||||||
|
booking: BookingWithRelations,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const admins = await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
role: 'ADMIN',
|
||||||
|
adminBookingSubscriptionCount: { gt: 0 },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
openid: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (admins.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const student = await this.prisma.user.findUnique({
|
||||||
|
where: { id: booking.userId },
|
||||||
|
select: { nickname: true, phone: true },
|
||||||
|
})
|
||||||
|
const studio = await this.studioService.getInfo()
|
||||||
|
const bookingDate = booking.timeSlot.date
|
||||||
|
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
|
||||||
|
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
|
||||||
|
|
||||||
|
const studentLabel = this.buildAdminBookingStudentLabel(student)
|
||||||
|
await Promise.allSettled(
|
||||||
|
admins
|
||||||
|
.filter((admin) => admin.openid)
|
||||||
|
.map((admin) => this.subscriptionMessageService.sendAdminBookingCreatedMessage({
|
||||||
|
openid: admin.openid,
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingContent: `${studentLabel}已预约`.slice(0, 20),
|
||||||
|
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
|
||||||
|
courseName: studio.name || '普拉提课程',
|
||||||
|
bookingPeriod: `${dateLabel} ${periodLabel}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send admin booking created subscription message failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAdminBookingStudentLabel(student: { nickname: string; phone: string | null } | null): string {
|
||||||
|
const nickname = (student?.nickname || '').trim()
|
||||||
|
if (nickname) {
|
||||||
|
return nickname.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = student?.phone || ''
|
||||||
|
if (phone.length >= 4) {
|
||||||
|
return `尾号${phone.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return '学员'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,14 +84,26 @@ describe('TimeSlotService', () => {
|
|||||||
expect(result[0].myBookingId).toBeNull()
|
expect(result[0].myBookingId).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks isBookedByMe=true and sets myBookingId when user has a CONFIRMED booking', async () => {
|
it('marks isBookedByMe=true and sets my booking info when user has a CONFIRMED booking', async () => {
|
||||||
const slot = makeSlot({ bookings: [{ id: 'booking-42' }] })
|
const slot = makeSlot({ bookings: [{ id: 'booking-42', status: BookingStatus.CONFIRMED }] })
|
||||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||||
|
|
||||||
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||||
|
|
||||||
expect(result[0].isBookedByMe).toBe(true)
|
expect(result[0].isBookedByMe).toBe(true)
|
||||||
expect(result[0].myBookingId).toBe('booking-42')
|
expect(result[0].myBookingId).toBe('booking-42')
|
||||||
|
expect(result[0].myBookingStatus).toBe(BookingStatus.CONFIRMED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks pending confirmation booking as already booked by current user', async () => {
|
||||||
|
const slot = makeSlot({ bookings: [{ id: 'booking-99', status: BookingStatus.PENDING_CONFIRMATION }] })
|
||||||
|
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||||
|
|
||||||
|
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||||
|
|
||||||
|
expect(result[0].isBookedByMe).toBe(true)
|
||||||
|
expect(result[0].myBookingId).toBe('booking-99')
|
||||||
|
expect(result[0].myBookingStatus).toBe(BookingStatus.PENDING_CONFIRMATION)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes CLOSED slots from query', async () => {
|
it('excludes CLOSED slots from query', async () => {
|
||||||
@@ -134,11 +146,12 @@ describe('TimeSlotService', () => {
|
|||||||
|
|
||||||
expect(result[0].isBookedByMe).toBe(false)
|
expect(result[0].isBookedByMe).toBe(false)
|
||||||
expect(result[0].myBookingId).toBeNull()
|
expect(result[0].myBookingId).toBeNull()
|
||||||
|
expect(result[0].myBookingStatus).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps multiple slots correctly', async () => {
|
it('maps multiple slots correctly', async () => {
|
||||||
const slots = [
|
const slots = [
|
||||||
makeSlot({ id: 'slot-1', startTime: '09:00', bookings: [{ id: 'bk-1' }] }),
|
makeSlot({ id: 'slot-1', startTime: '09:00', bookings: [{ id: 'bk-1', status: BookingStatus.CONFIRMED }] }),
|
||||||
makeSlot({ id: 'slot-2', startTime: '10:00', bookings: [] }),
|
makeSlot({ id: 'slot-2', startTime: '10:00', bookings: [] }),
|
||||||
]
|
]
|
||||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(slots)
|
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(slots)
|
||||||
@@ -148,8 +161,10 @@ describe('TimeSlotService', () => {
|
|||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
expect(result[0].isBookedByMe).toBe(true)
|
expect(result[0].isBookedByMe).toBe(true)
|
||||||
expect(result[0].myBookingId).toBe('bk-1')
|
expect(result[0].myBookingId).toBe('bk-1')
|
||||||
|
expect(result[0].myBookingStatus).toBe(BookingStatus.CONFIRMED)
|
||||||
expect(result[1].isBookedByMe).toBe(false)
|
expect(result[1].isBookedByMe).toBe(false)
|
||||||
expect(result[1].myBookingId).toBeNull()
|
expect(result[1].myBookingId).toBeNull()
|
||||||
|
expect(result[1].myBookingStatus).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -164,7 +179,20 @@ describe('TimeSlotService', () => {
|
|||||||
|
|
||||||
const result = await service.getSlotById('slot-1')
|
const result = await service.getSlotById('slot-1')
|
||||||
|
|
||||||
expect(result).toEqual(slot)
|
expect(result).toMatchObject({
|
||||||
|
id: slot.id,
|
||||||
|
date: '2026-04-07',
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
capacity: slot.capacity,
|
||||||
|
bookedCount: slot.bookedCount,
|
||||||
|
status: slot.status,
|
||||||
|
source: slot.source,
|
||||||
|
templateId: slot.templateId,
|
||||||
|
isBookedByMe: false,
|
||||||
|
myBookingId: null,
|
||||||
|
myBookingStatus: null,
|
||||||
|
})
|
||||||
expect(mockPrisma.timeSlot.findUnique).toHaveBeenCalledWith(
|
expect(mockPrisma.timeSlot.findUnique).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ where: { id: 'slot-1' } }),
|
expect.objectContaining({ where: { id: 'slot-1' } }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class TimeSlotService {
|
|||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
},
|
},
|
||||||
myBooking: { id: string } | null,
|
myBooking: { id: string; status: string } | null,
|
||||||
): TimeSlotWithBookingStatus {
|
): TimeSlotWithBookingStatus {
|
||||||
return {
|
return {
|
||||||
id: slot.id,
|
id: slot.id,
|
||||||
@@ -48,6 +48,7 @@ export class TimeSlotService {
|
|||||||
updatedAt: slot.updatedAt.toISOString(),
|
updatedAt: slot.updatedAt.toISOString(),
|
||||||
isBookedByMe: myBooking !== null,
|
isBookedByMe: myBooking !== null,
|
||||||
myBookingId: myBooking?.id ?? null,
|
myBookingId: myBooking?.id ?? null,
|
||||||
|
myBookingStatus: (myBooking?.status as BookingStatus | undefined) ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +72,9 @@ export class TimeSlotService {
|
|||||||
? {
|
? {
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
status: BookingStatus.CONFIRMED,
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
}
|
}
|
||||||
: false,
|
: false,
|
||||||
},
|
},
|
||||||
@@ -97,9 +98,9 @@ export class TimeSlotService {
|
|||||||
? {
|
? {
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
status: BookingStatus.CONFIRMED,
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
}
|
}
|
||||||
: false,
|
: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const makeUser = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
nickname: 'Alice',
|
nickname: 'Alice',
|
||||||
avatarUrl: 'https://example.com/avatar.png',
|
avatarUrl: 'https://example.com/avatar.png',
|
||||||
role: UserRole.MEMBER,
|
role: UserRole.MEMBER,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
_count: { memberships: 2 },
|
_count: { memberships: 2 },
|
||||||
@@ -119,12 +120,20 @@ describe('UserService', () => {
|
|||||||
avatarUrl: 'https://example.com/avatar.png',
|
avatarUrl: 'https://example.com/avatar.png',
|
||||||
role: UserRole.MEMBER,
|
role: UserRole.MEMBER,
|
||||||
activeMembershipCount: 3,
|
activeMembershipCount: 3,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
subscriptionMessageTemplates: {
|
subscriptionMessageTemplates: {
|
||||||
templates: [
|
templates: [
|
||||||
{
|
{
|
||||||
templateId: 'tmpl-booking-confirmed',
|
templateId: 'tmpl-booking-confirmed',
|
||||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||||
|
usageTarget: 'consent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||||
|
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
|
||||||
|
usageTarget: 'counter',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -255,7 +264,38 @@ describe('UserService', () => {
|
|||||||
expect(result.nickname).toBe('Bob')
|
expect(result.nickname).toBe('Bob')
|
||||||
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
||||||
expect(result.activeMembershipCount).toBe(1)
|
expect(result.activeMembershipCount).toBe(1)
|
||||||
expect(result.subscriptionMessageTemplates.templates).toHaveLength(1)
|
expect(result.adminBookingSubscriptionCount).toBe(0)
|
||||||
|
expect(result.subscriptionMessageTemplates.templates).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('increments admin booking subscription count for admin users', async () => {
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(makeUser({
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
adminBookingSubscriptionCount: 2,
|
||||||
|
}))
|
||||||
|
mockPrisma.user.update.mockResolvedValue(makeUser({
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
adminBookingSubscriptionCount: 3,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await service.grantAdminBookingSubscriptionCount('user-1')
|
||||||
|
|
||||||
|
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-1' },
|
||||||
|
data: {
|
||||||
|
adminBookingSubscriptionCount: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
memberships: { where: { status: MembershipStatus.ACTIVE } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.adminBookingSubscriptionCount).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('only includes provided fields in the update payload', async () => {
|
it('only includes provided fields in the update payload', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { ConfigService } from '@nestjs/config'
|
import { ConfigService } from '@nestjs/config'
|
||||||
|
import { SubscriptionMessageScene } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
interface BookingConfirmedTemplatePayload {
|
interface BookingConfirmedTemplatePayload {
|
||||||
@@ -15,6 +16,15 @@ interface BookingConfirmedTemplatePayload {
|
|||||||
readonly bookingPeriod: string
|
readonly bookingPeriod: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AdminBookingCreatedTemplatePayload {
|
||||||
|
readonly openid: string
|
||||||
|
readonly bookingId: string
|
||||||
|
readonly bookingContent: string
|
||||||
|
readonly bookingTime: string
|
||||||
|
readonly courseName: string
|
||||||
|
readonly bookingPeriod: string
|
||||||
|
}
|
||||||
|
|
||||||
interface WechatAccessTokenResponse {
|
interface WechatAccessTokenResponse {
|
||||||
access_token?: string
|
access_token?: string
|
||||||
expires_in?: number
|
expires_in?: number
|
||||||
@@ -50,6 +60,60 @@ export class SubscriptionMessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
||||||
|
return this.sendConsentBasedBookingMessage(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAdminBookingCreatedMessage(payload: AdminBookingCreatedTemplatePayload): Promise<boolean> {
|
||||||
|
const templateId = this.getBookingConfirmedTemplateId()
|
||||||
|
if (!templateId) {
|
||||||
|
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED is not configured, skip sending admin subscription message')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = await this.prisma.user.findUnique({
|
||||||
|
where: { openid: payload.openid },
|
||||||
|
select: { id: true, adminBookingSubscriptionCount: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
this.logger.warn(`Admin user not found for subscription send: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.adminBookingSubscriptionCount <= 0) {
|
||||||
|
this.logger.warn(`Admin subscription quota exhausted: ${stringifyDebugPayload({ userId: adminUser.id, bookingId: payload.bookingId, remainingCount: adminUser.adminBookingSubscriptionCount, templateId })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await this.sendWechatSubscribeMessage({
|
||||||
|
openid: payload.openid,
|
||||||
|
bookingId: payload.bookingId,
|
||||||
|
templateId,
|
||||||
|
payload,
|
||||||
|
logContext: {
|
||||||
|
target: 'admin',
|
||||||
|
userId: adminUser.id,
|
||||||
|
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: adminUser.id },
|
||||||
|
data: {
|
||||||
|
adminBookingSubscriptionCount: {
|
||||||
|
decrement: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendConsentBasedBookingMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
||||||
const templateId = this.getBookingConfirmedTemplateId()
|
const templateId = this.getBookingConfirmedTemplateId()
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED is not configured, skip sending subscription message')
|
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED is not configured, skip sending subscription message')
|
||||||
@@ -60,7 +124,7 @@ export class SubscriptionMessageService {
|
|||||||
where: {
|
where: {
|
||||||
user: { openid: payload.openid },
|
user: { openid: payload.openid },
|
||||||
templateId,
|
templateId,
|
||||||
scene: 'BOOKING_CREATED',
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
acceptCount: { gt: 0 },
|
acceptCount: { gt: 0 },
|
||||||
totalRequestCount: { gt: 0 },
|
totalRequestCount: { gt: 0 },
|
||||||
},
|
},
|
||||||
@@ -71,7 +135,7 @@ export class SubscriptionMessageService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!consent) {
|
if (!consent) {
|
||||||
this.logger.warn(`No subscription quota found: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId, scene: 'BOOKING_CREATED' })}`)
|
this.logger.warn(`No subscription quota found: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId, scene: SubscriptionMessageScene.BOOKING_CREATED })}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,21 +144,55 @@ export class SubscriptionMessageService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sent = await this.sendWechatSubscribeMessage({
|
||||||
|
openid: payload.openid,
|
||||||
|
bookingId: payload.bookingId,
|
||||||
|
templateId,
|
||||||
|
payload,
|
||||||
|
logContext: {
|
||||||
|
target: 'member',
|
||||||
|
consentId: consent.id,
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.subscriptionMessageConsent.update({
|
||||||
|
where: { id: consent.id },
|
||||||
|
data: {
|
||||||
|
sentCount: { increment: 1 },
|
||||||
|
lastSentAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendWechatSubscribeMessage(params: {
|
||||||
|
openid: string
|
||||||
|
bookingId: string
|
||||||
|
templateId: string
|
||||||
|
payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
|
||||||
|
logContext: Record<string, unknown>
|
||||||
|
}): Promise<boolean> {
|
||||||
const accessToken = await this.getAccessToken()
|
const accessToken = await this.getAccessToken()
|
||||||
const page = `/pages/booking/detail?id=${payload.bookingId}`
|
const page = `/pages/booking/detail?id=${params.bookingId}`
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
touser: payload.openid,
|
touser: params.openid,
|
||||||
template_id: templateId,
|
template_id: params.templateId,
|
||||||
page,
|
page,
|
||||||
data: {
|
data: {
|
||||||
thing1: { value: payload.bookingContent.slice(0, 20) },
|
thing1: { value: params.payload.bookingContent.slice(0, 20) },
|
||||||
time2: { value: payload.bookingTime.slice(0, 20) },
|
time2: { value: params.payload.bookingTime.slice(0, 20) },
|
||||||
thing25: { value: payload.courseName.slice(0, 20) },
|
thing25: { value: params.payload.courseName.slice(0, 20) },
|
||||||
time35: { value: payload.bookingPeriod.slice(0, 20) },
|
time35: { value: params.payload.bookingPeriod.slice(0, 20) },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, requestBody, consentId: consent.id })}`)
|
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
|
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
|
||||||
@@ -109,26 +207,17 @@ export class SubscriptionMessageService {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const responseText = await response.text()
|
const responseText = await response.text()
|
||||||
this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, bookingId: payload.bookingId, templateId, requestBody })}`)
|
this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
|
||||||
throw new InternalServerErrorException('调用微信订阅消息接口失败')
|
throw new InternalServerErrorException('调用微信订阅消息接口失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = (await response.json()) as WechatSubscribeSendResponse
|
const result = (await response.json()) as WechatSubscribeSendResponse
|
||||||
if (result.errcode && result.errcode !== 0) {
|
if (result.errcode && result.errcode !== 0) {
|
||||||
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, requestBody, response: result, consentId: consent.id })}`)
|
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, response: result, consentId: consent.id })}`)
|
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, response: result, ...params.logContext })}`)
|
||||||
|
|
||||||
await this.prisma.subscriptionMessageConsent.update({
|
|
||||||
where: { id: consent.id },
|
|
||||||
data: {
|
|
||||||
sentCount: { increment: 1 },
|
|
||||||
lastSentAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export class UserController {
|
|||||||
return this.userService.reportSubscriptionMessageRequests(userId, dto.requests)
|
return this.userService.reportSubscriptionMessageRequests(userId, dto.requests)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('user/subscription-messages/admin-booking-count')
|
||||||
|
increaseAdminBookingSubscriptionCount(@CurrentUser('sub') userId: string) {
|
||||||
|
return this.userService.grantAdminBookingSubscriptionCount(userId)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Admin: Member Management ─────────────────────────────────────────────
|
// ─── Admin: Member Management ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('admin/members')
|
@Get('admin/members')
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
SubscriptionMessageConsentSummary,
|
SubscriptionMessageConsentSummary,
|
||||||
SubscriptionMessageRequestItem,
|
SubscriptionMessageRequestItem,
|
||||||
SubscriptionMessageRequestResult,
|
SubscriptionMessageRequestResult,
|
||||||
|
SubscriptionMessageTemplate,
|
||||||
SubscriptionMessageTemplateConfig,
|
SubscriptionMessageTemplateConfig,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { ConfigService } from '@nestjs/config'
|
import { ConfigService } from '@nestjs/config'
|
||||||
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service'
|
|||||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||||
|
|
||||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||||
|
const ADMIN_BOOKING_SUBSCRIPTION_INCREMENT = 1
|
||||||
|
|
||||||
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
|
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
|
||||||
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
|
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
|
||||||
@@ -32,14 +34,46 @@ export class UserService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||||
return {
|
const templates = [
|
||||||
templates: [
|
|
||||||
{
|
{
|
||||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||||
|
usageTarget: 'consent' as const,
|
||||||
},
|
},
|
||||||
].filter((item) => item.templateId),
|
{
|
||||||
|
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 mapProfile(user: {
|
||||||
|
id: string
|
||||||
|
phone: string | null
|
||||||
|
nickname: string
|
||||||
|
avatarUrl: string | null
|
||||||
|
role: string
|
||||||
|
adminBookingSubscriptionCount: number
|
||||||
|
createdAt: Date
|
||||||
|
_count: { memberships: number }
|
||||||
|
}): UserProfileResponse {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role as UserRole,
|
||||||
|
activeMembershipCount: user._count.memberships,
|
||||||
|
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
||||||
|
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,16 +95,7 @@ export class UserService {
|
|||||||
throw new NotFoundException('User not found')
|
throw new NotFoundException('User not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return this.mapProfile(user)
|
||||||
id: user.id,
|
|
||||||
phone: user.phone,
|
|
||||||
nickname: user.nickname,
|
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
role: user.role as UserRole,
|
|
||||||
activeMembershipCount: user._count.memberships,
|
|
||||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
|
||||||
createdAt: user.createdAt.toISOString(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
@@ -94,16 +119,7 @@ export class UserService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return this.mapProfile(updated)
|
||||||
id: updated.id,
|
|
||||||
phone: updated.phone,
|
|
||||||
nickname: updated.nickname,
|
|
||||||
avatarUrl: updated.avatarUrl,
|
|
||||||
role: updated.role as UserRole,
|
|
||||||
activeMembershipCount: updated._count.memberships,
|
|
||||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
|
||||||
createdAt: updated.createdAt.toISOString(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSubscriptionMessageTemplates(): SubscriptionMessageTemplateConfig {
|
getSubscriptionMessageTemplates(): SubscriptionMessageTemplateConfig {
|
||||||
@@ -181,6 +197,49 @@ export class UserService {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async grantAdminBookingSubscriptionCount(userId: string): Promise<UserProfileResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
memberships: {
|
||||||
|
where: { status: MembershipStatus.ACTIVE },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== UserRole.ADMIN) {
|
||||||
|
return this.mapProfile(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
adminBookingSubscriptionCount: {
|
||||||
|
increment: ADMIN_BOOKING_SUBSCRIPTION_INCREMENT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
memberships: {
|
||||||
|
where: { status: MembershipStatus.ACTIVE },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.mapProfile(updated)
|
||||||
|
}
|
||||||
|
|
||||||
async getStats(userId: string): Promise<UserStatsResponse> {
|
async getStats(userId: string): Promise<UserStatsResponse> {
|
||||||
const completedBookings = await this.prisma.booking.findMany({
|
const completedBookings = await this.prisma.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -63,4 +63,5 @@ export enum FlashSaleOrderStatus {
|
|||||||
export enum SubscriptionMessageScene {
|
export enum SubscriptionMessageScene {
|
||||||
ORDER_PAID = 'ORDER_PAID',
|
ORDER_PAID = 'ORDER_PAID',
|
||||||
BOOKING_CREATED = 'BOOKING_CREATED',
|
BOOKING_CREATED = 'BOOKING_CREATED',
|
||||||
|
ADMIN_BOOKING_CREATED = 'ADMIN_BOOKING_CREATED',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface SubscriptionMessageTemplate {
|
|||||||
readonly templateId: string
|
readonly templateId: string
|
||||||
readonly scene: SubscriptionMessageScene
|
readonly scene: SubscriptionMessageScene
|
||||||
readonly description: string
|
readonly description: string
|
||||||
|
readonly usageTarget?: 'consent' | 'counter'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionMessageTemplateConfig {
|
export interface SubscriptionMessageTemplateConfig {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TimeSlotStatus, TimeSlotSource } from '../enums'
|
import { BookingStatus, TimeSlotStatus, TimeSlotSource } from '../enums'
|
||||||
|
|
||||||
export interface TimeSlot {
|
export interface TimeSlot {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
@@ -19,6 +19,8 @@ export interface TimeSlotWithBookingStatus extends TimeSlot {
|
|||||||
readonly isBookedByMe: boolean
|
readonly isBookedByMe: boolean
|
||||||
/** 当前用户在此时段的预约 ID(用于取消) */
|
/** 当前用户在此时段的预约 ID(用于取消) */
|
||||||
readonly myBookingId: string | null
|
readonly myBookingId: string | null
|
||||||
|
/** 当前用户在此时段的预约状态,仅在 isBookedByMe=true 时有值 */
|
||||||
|
readonly myBookingStatus: BookingStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateManualSlotDto {
|
export interface CreateManualSlotDto {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface UserProfileResponse {
|
|||||||
readonly avatarUrl: string | null
|
readonly avatarUrl: string | null
|
||||||
readonly role: UserRole
|
readonly role: UserRole
|
||||||
readonly activeMembershipCount: number
|
readonly activeMembershipCount: number
|
||||||
|
readonly adminBookingSubscriptionCount: number
|
||||||
readonly subscriptionMessageTemplates: SubscriptionMessageTemplateConfig
|
readonly subscriptionMessageTemplates: SubscriptionMessageTemplateConfig
|
||||||
readonly createdAt: string
|
readonly createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user