feat: 支持管理员消息推送
This commit is contained in:
@@ -47,6 +47,7 @@ interface MenuItem {
|
||||
const props = defineProps<{
|
||||
isAdmin: boolean
|
||||
requireAuth?: boolean
|
||||
activeMembershipCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -56,12 +57,17 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
|
||||
? `${props.activeMembershipCount}张`
|
||||
: undefined
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'membership',
|
||||
type: 'item',
|
||||
title: '我的会员卡',
|
||||
path: '/pages/profile/membership',
|
||||
badge: membershipBadge,
|
||||
requireAuth: true,
|
||||
},
|
||||
{
|
||||
@@ -346,11 +352,17 @@ function handleTap(item: MenuItem) {
|
||||
|
||||
&__badge {
|
||||
font-size: 22rpx;
|
||||
color: #ffffff;
|
||||
background: $error-color;
|
||||
border-radius: 20rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
color: #496578;
|
||||
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;
|
||||
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 {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<!-- OPEN + booked by me -->
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
||||
<view class="action-badge badge-booked">
|
||||
<text>已预约</text>
|
||||
<text>{{ myBookingLabel }}</text>
|
||||
</view>
|
||||
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||
<text>取消</text>
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { isSlotPast } from '../utils/format'
|
||||
|
||||
@@ -120,6 +120,12 @@ const durationMin = computed(() => {
|
||||
return (eh * 60 + em) - (sh * 60 + sm)
|
||||
})
|
||||
|
||||
const myBookingLabel = computed(() => (
|
||||
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
||||
? '已预约待确认'
|
||||
: '已预约'
|
||||
))
|
||||
|
||||
const capacityLabel = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.timeSlot
|
||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
|
||||
@@ -1,40 +1,64 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<view class="location-row">
|
||||
<view class="location-left" @tap="handleAddressTap">
|
||||
<view class="location-icon" />
|
||||
<text class="location-text">
|
||||
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
||||
</text>
|
||||
<view class="location-content">
|
||||
<text class="location-label">场馆地址</text>
|
||||
<text class="location-text">
|
||||
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="chat-btn" open-type="contact">
|
||||
<view class="chat-icon" />
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
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) {
|
||||
if (!props.studioInfo?.photos?.length) return
|
||||
uni.previewImage({
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx 28rpx;
|
||||
padding: 28rpx 32rpx 24rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
@@ -114,11 +117,64 @@ function copyAddress() {
|
||||
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 {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
color: #5f5955;
|
||||
line-height: 1.6;
|
||||
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 ── */
|
||||
|
||||
@@ -25,14 +25,16 @@
|
||||
mode="aspectFill"
|
||||
@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 class="user-card__info">
|
||||
<view class="user-card__name-row">
|
||||
<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>
|
||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||||
</view>
|
||||
@@ -75,7 +77,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
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<{
|
||||
loggedIn: boolean
|
||||
@@ -117,24 +119,25 @@ const maskedPhone = computed(() => {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
})
|
||||
|
||||
// Derive VIP level from active memberships count
|
||||
const activeMemberships = computed(() =>
|
||||
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
||||
)
|
||||
|
||||
const vipLevel = computed(() => {
|
||||
const count = activeMemberships.value.length
|
||||
if (count >= 3) return 'VIP3'
|
||||
if (count >= 2) return 'VIP2'
|
||||
if (count >= 1) return 'VIP1'
|
||||
return null
|
||||
})
|
||||
const activeMembershipCount = computed(
|
||||
() => props.user?.activeMembershipCount ?? activeMemberships.value.length,
|
||||
)
|
||||
|
||||
const hasMembership = computed(() => activeMembershipCount.value > 0)
|
||||
|
||||
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
|
||||
const remainingSessions = computed(() =>
|
||||
activeMemberships.value
|
||||
.filter((m) => m.cardType.type === 'TIMES')
|
||||
.reduce((sum, m) => sum + m.remainingCount, 0),
|
||||
.filter((m) => m.cardType.type === CardTypeCategory.TIMES)
|
||||
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
||||
)
|
||||
|
||||
function onAvatarError() {
|
||||
@@ -220,24 +223,6 @@ function handleLogin() {
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -257,6 +242,59 @@ function handleLogin() {
|
||||
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 {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
|
||||
@@ -169,6 +169,21 @@
|
||||
<text class="arrow-text">›</text>
|
||||
</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 style="height: 40rpx" />
|
||||
@@ -177,17 +192,24 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import type { AdminStats } from '../../stores/admin'
|
||||
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
|
||||
const statsLoading = ref(false)
|
||||
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
||||
const adminSubscribeLoading = ref(false)
|
||||
|
||||
function navigate(path: string) {
|
||||
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(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
loadStats()
|
||||
userStore.fetchProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -347,6 +392,7 @@ onMounted(() => {
|
||||
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
|
||||
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
|
||||
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
|
||||
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
|
||||
|
||||
.item-text-group {
|
||||
display: flex;
|
||||
|
||||
@@ -207,6 +207,11 @@
|
||||
<text class="action-btn-text">立即预约</text>
|
||||
</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 class="action-btn action-btn--disabled">
|
||||
<text class="action-btn-text">已约满</text>
|
||||
@@ -293,6 +298,14 @@ const canBook = computed(() => {
|
||||
return !isSlotPast(slotData.value.date, slotData.value.startTime)
|
||||
})
|
||||
|
||||
const slotActionLabel = computed(() => {
|
||||
if (slotData.value?.myBookingStatus === BookingStatus.PENDING_CONFIRMATION) {
|
||||
return '已预约待确认'
|
||||
}
|
||||
|
||||
return '已预约'
|
||||
})
|
||||
|
||||
const slotCapacityClass = computed(() => {
|
||||
if (!slotData.value) return ''
|
||||
const { bookedCount, capacity } = slotData.value
|
||||
@@ -346,6 +359,11 @@ async function loadSlotData() {
|
||||
|
||||
// ─── Slot mode: Booking flow ─────────────────────────────────────────────
|
||||
async function handleSlotBook() {
|
||||
if (slotData.value?.isBookedByMe) {
|
||||
goToMyBookingDetail()
|
||||
return
|
||||
}
|
||||
|
||||
if (!userStore.loggedIn) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
@@ -386,6 +404,11 @@ async function handleSlotBook() {
|
||||
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 }) {
|
||||
showConfirmPopup.value = false
|
||||
uni.showLoading({ title: '预约中...' })
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
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 { useUserStore } from '../../stores/user'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
@@ -196,6 +196,19 @@ function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
|
||||
|
||||
// ─── Book flow ────────────────────────────────────────────
|
||||
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
|
||||
if (!userStore.loggedIn) {
|
||||
uni.showModal({
|
||||
|
||||
@@ -123,6 +123,7 @@ async function refreshData() {
|
||||
|
||||
if (userStore.loggedIn) {
|
||||
tasks.push(
|
||||
userStore.fetchProfile(),
|
||||
userStore.fetchMemberships(),
|
||||
bookingStore.fetchUpcomingBookings(),
|
||||
)
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
||||
|
||||
<!-- Menu section: always visible -->
|
||||
<ProfileMenu :is-admin="isAdmin" :require-auth="loggedIn" @clear-cache="handleClearCache" @about="handleAbout"
|
||||
@require-login="handleLogin" />
|
||||
<ProfileMenu
|
||||
:is-admin="isAdmin"
|
||||
:require-auth="loggedIn"
|
||||
:active-membership-count="activeMembershipCount"
|
||||
@clear-cache="handleClearCache"
|
||||
@about="handleAbout"
|
||||
@require-login="handleLogin"
|
||||
/>
|
||||
|
||||
<!-- Logout button: only when logged in -->
|
||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||
@@ -19,7 +25,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
@@ -35,6 +41,10 @@ const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(
|
||||
const loginLoading = ref(false)
|
||||
const navBarHeight = ref(64)
|
||||
|
||||
const activeMembershipCount = computed(
|
||||
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||
)
|
||||
|
||||
// ─── 微信分享 ───────────────────────────────────────────────
|
||||
onShareAppMessage(() => {
|
||||
return {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const result = await wxLogin()
|
||||
token.value = result.token
|
||||
user.value = result.user
|
||||
cacheSubscriptionMessageTemplateConfig(result.user.subscriptionMessageTemplates)
|
||||
return { user: result.user, isNewUser: result.isNewUser }
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err)
|
||||
@@ -61,6 +62,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
try {
|
||||
user.value = await get<UserProfileResponse>('/user/profile')
|
||||
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
||||
return user.value
|
||||
} catch (err) {
|
||||
console.error('Fetch profile failed:', err)
|
||||
}
|
||||
@@ -87,9 +89,15 @@ export const useUserStore = defineStore('user', () => {
|
||||
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
||||
const updated = await put<UserProfileResponse>('/user/profile', data)
|
||||
user.value = updated
|
||||
cacheSubscriptionMessageTemplateConfig(updated.subscriptionMessageTemplates)
|
||||
return updated
|
||||
}
|
||||
|
||||
function setProfile(profile: UserProfileResponse) {
|
||||
user.value = profile
|
||||
cacheSubscriptionMessageTemplateConfig(profile.subscriptionMessageTemplates)
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
if (isLoggedIn()) {
|
||||
fetchProfile()
|
||||
@@ -122,6 +130,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
fetchStats,
|
||||
fetchMemberships,
|
||||
updateProfile,
|
||||
setProfile,
|
||||
checkAuth,
|
||||
logout,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SubscriptionMessageRequestItem,
|
||||
SubscriptionMessageTemplate,
|
||||
SubscriptionMessageTemplateConfig,
|
||||
UserProfileResponse,
|
||||
} from '@mp-pilates/shared'
|
||||
import { post } from './request'
|
||||
|
||||
@@ -30,16 +31,20 @@ function stringifyDebugPayload(payload: unknown): string {
|
||||
|
||||
function getSubscribeDebugContext() {
|
||||
try {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const host = systemInfo.host as { env?: string } | string | undefined
|
||||
const windowInfo = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||
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 {
|
||||
platform: systemInfo.platform,
|
||||
platform: deviceInfo?.platform ?? 'unknown',
|
||||
hostEnv: typeof host === 'object' && host ? host.env : undefined,
|
||||
app: systemInfo.appName,
|
||||
system: systemInfo.system,
|
||||
language: systemInfo.language,
|
||||
version: systemInfo.version,
|
||||
SDKVersion: systemInfo.SDKVersion,
|
||||
app: appBaseInfo?.appName,
|
||||
system: deviceInfo?.system,
|
||||
language: appBaseInfo?.language,
|
||||
version: appBaseInfo?.version,
|
||||
SDKVersion: appBaseInfo?.SDKVersion,
|
||||
windowWidth: windowInfo?.windowWidth,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
@@ -182,6 +187,61 @@ export async function requestBookingCreatedSubscriptionMessage(): Promise<Subscr
|
||||
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 {
|
||||
cachedConfig = null
|
||||
uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY)
|
||||
|
||||
Reference in New Issue
Block a user