feat: 支持管理员消息推送

This commit is contained in:
richarjiang
2026-04-12 22:18:34 +08:00
parent c60821c5ff
commit 6cee28bf66
26 changed files with 780 additions and 161 deletions

View File

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

View File

@@ -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 '已关闭'

View File

@@ -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 ── */

View File

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