feat: 优化微信订阅消息体系并新增约课取消通知与开课前1小时提醒
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -96,6 +96,11 @@
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Subscribe note -->
|
||||
<view class="subscribe-tip">
|
||||
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果、课前1小时提醒与取消通知</text>
|
||||
</view>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<view class="action-row">
|
||||
<view class="btn-outline" @tap="handleCancel">
|
||||
@@ -210,6 +215,8 @@ function handleMaskTap() {
|
||||
.no-card-text { font-size: 24rpx; color: #8b817b; }
|
||||
.deduction-tip { padding: 18rpx 4rpx; }
|
||||
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
|
||||
.subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
|
||||
.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
|
||||
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
|
||||
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
|
||||
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
|
||||
|
||||
@@ -24,7 +24,7 @@ interface MenuItem {
|
||||
title?: string
|
||||
path?: string
|
||||
isAdmin?: boolean
|
||||
action?: 'clear'
|
||||
action?: 'clear' | 'notifications'
|
||||
requireAuth?: boolean
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear-cache'): void
|
||||
(e: 'require-login'): void
|
||||
(e: 'open-notifications'): void
|
||||
}>()
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
@@ -64,6 +65,13 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
path: '/pages/profile/info',
|
||||
requireAuth: true,
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
type: 'item',
|
||||
title: '消息提醒设置',
|
||||
action: 'notifications',
|
||||
requireAuth: true,
|
||||
},
|
||||
{
|
||||
key: 'sep1',
|
||||
type: 'separator',
|
||||
@@ -98,6 +106,8 @@ function handleTap(item: MenuItem) {
|
||||
}
|
||||
if (item.action === 'clear') {
|
||||
emit('clear-cache')
|
||||
} else if (item.action === 'notifications') {
|
||||
emit('open-notifications')
|
||||
} else if (item.path) {
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
|
||||
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal file
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<view v-if="visible" class="modal-mask" @tap="handleClose">
|
||||
<view class="modal-panel" @tap.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">微信消息提醒设置</text>
|
||||
<view class="close-btn" @tap="handleClose">
|
||||
<text class="close-icon">✕</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="notice-box">
|
||||
<text class="notice-title">💡 为什么需要增加订阅次数?</text>
|
||||
<text class="notice-desc">
|
||||
微信订阅消息每授权 1 次可接收 1 条通知。建议点击下方按钮,并在弹出的微信授权窗中勾选<text class="notice-highlight">「总是保持以上选择」</text>,即可永久无感自动接收课程变动与上课提醒。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="quota-list">
|
||||
<view v-if="loading" class="loading-wrap">
|
||||
<text class="loading-text">加载提醒状态中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="quotas.length === 0" class="empty-wrap">
|
||||
<text class="empty-text">未检测到可用提醒模板配置</text>
|
||||
</view>
|
||||
|
||||
<view v-else v-for="item in quotas" :key="item.scene" class="quota-item">
|
||||
<view class="quota-info">
|
||||
<view class="quota-title-row">
|
||||
<text class="quota-icon">{{ getSceneIcon(item.scene) }}</text>
|
||||
<text class="quota-name">{{ getSceneName(item.scene) }}</text>
|
||||
</view>
|
||||
<text class="quota-desc">{{ item.description }}</text>
|
||||
</view>
|
||||
|
||||
<view class="quota-badge" :class="getBadgeClass(item.remainingQuota)">
|
||||
<text class="badge-text">
|
||||
{{ item.remainingQuota > 0 ? `余 ${item.remainingQuota} 次` : '待补充' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
:loading="subscribing"
|
||||
:disabled="subscribing"
|
||||
@tap="handleTopUp"
|
||||
>
|
||||
一键补充全部提醒次数 (+3)
|
||||
</button>
|
||||
|
||||
<view class="btn-secondary" @tap="handleOpenSettings">
|
||||
<text class="btn-secondary-text">微信权限与通知设置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { SubscriptionMessageScene } from '@mp-pilates/shared'
|
||||
import type { SubscriptionQuotaItem } from '@mp-pilates/shared'
|
||||
import {
|
||||
fetchUserSubscriptionQuotas,
|
||||
requestBookingBundleSubscriptionMessage,
|
||||
openSubscribeSettings,
|
||||
} from '../utils/wechat-subscription'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
}>()
|
||||
|
||||
const quotas = ref<SubscriptionQuotaItem[]>([])
|
||||
const loading = ref(false)
|
||||
const subscribing = ref(false)
|
||||
|
||||
async function loadQuotas() {
|
||||
loading.value = true
|
||||
try {
|
||||
quotas.value = await fetchUserSubscriptionQuotas()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
void loadQuotas()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function getSceneIcon(scene: SubscriptionMessageScene): string {
|
||||
switch (scene) {
|
||||
case SubscriptionMessageScene.BOOKING_CREATED:
|
||||
return '📅'
|
||||
case SubscriptionMessageScene.CLASS_REMINDER:
|
||||
return '⏰'
|
||||
case SubscriptionMessageScene.BOOKING_CANCELLED:
|
||||
return '📋'
|
||||
case SubscriptionMessageScene.CLASS_REVIEW:
|
||||
return '⭐'
|
||||
default:
|
||||
return '🔔'
|
||||
}
|
||||
}
|
||||
|
||||
function getSceneName(scene: SubscriptionMessageScene): string {
|
||||
switch (scene) {
|
||||
case SubscriptionMessageScene.BOOKING_CREATED:
|
||||
return '约课成功通知'
|
||||
case SubscriptionMessageScene.CLASS_REMINDER:
|
||||
return '上课前 1 小时提醒'
|
||||
case SubscriptionMessageScene.BOOKING_CANCELLED:
|
||||
return '约课取消通知'
|
||||
case SubscriptionMessageScene.CLASS_REVIEW:
|
||||
return '课后评价提醒'
|
||||
default:
|
||||
return '课程服务通知'
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeClass(quota: number): string {
|
||||
if (quota >= 3) return 'badge--healthy'
|
||||
if (quota > 0) return 'badge--warning'
|
||||
return 'badge--danger'
|
||||
}
|
||||
|
||||
async function handleTopUp() {
|
||||
if (subscribing.value) return
|
||||
subscribing.value = true
|
||||
|
||||
try {
|
||||
const results = await requestBookingBundleSubscriptionMessage()
|
||||
const acceptedCount = results.filter((r) => r.result === 'accept').length
|
||||
if (acceptedCount > 0) {
|
||||
uni.showToast({ title: `已成功补充 ${acceptedCount} 项提醒额度`, icon: 'success' })
|
||||
await loadQuotas()
|
||||
} else {
|
||||
uni.showToast({ title: '未增加额度,可再次点击尝试', icon: 'none' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[subscribe] top-up failed', error)
|
||||
uni.showToast({ title: '授权未完成,可进入微信设置检查', icon: 'none' })
|
||||
} finally {
|
||||
subscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenSettings() {
|
||||
await openSubscribeSettings()
|
||||
await loadQuotas()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(56, 48, 42, 0.45);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #fbf9f6;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #514943;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 28rpx;
|
||||
color: #8b817b;
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
background: #f0f4ee;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #476d54;
|
||||
}
|
||||
|
||||
.notice-desc {
|
||||
font-size: 22rpx;
|
||||
color: #657568;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notice-highlight {
|
||||
font-weight: 600;
|
||||
color: #3b5a45;
|
||||
}
|
||||
|
||||
.quota-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
max-height: 480rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading-wrap,
|
||||
.empty-wrap {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 24rpx;
|
||||
color: #8b817b;
|
||||
}
|
||||
|
||||
.quota-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff;
|
||||
border: 2rpx solid #eee8e3;
|
||||
border-radius: 20rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.quota-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.quota-icon {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.quota-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #514943;
|
||||
}
|
||||
|
||||
.quota-desc {
|
||||
font-size: 21rpx;
|
||||
color: #8b817b;
|
||||
}
|
||||
|
||||
.quota-badge {
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge--healthy {
|
||||
background: #edf5eb;
|
||||
.badge-text {
|
||||
color: #4f7957;
|
||||
}
|
||||
}
|
||||
|
||||
.badge--warning {
|
||||
background: #fdf5ea;
|
||||
.badge-text {
|
||||
color: #b07d39;
|
||||
}
|
||||
}
|
||||
|
||||
.badge--danger {
|
||||
background: #fbeee9;
|
||||
.badge-text {
|
||||
color: #bc5e4c;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #6b8276;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
&:active {
|
||||
background: #597264;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-secondary-text {
|
||||
font-size: 25rpx;
|
||||
color: #7b8974;
|
||||
}
|
||||
</style>
|
||||
@@ -90,6 +90,9 @@
|
||||
</view>
|
||||
|
||||
<ClassReviewForm v-if="booking && booking.userId === userStore.user?.id && booking.status === BookingStatus.COMPLETED" :key="booking.id" :booking-id="booking.id" />
|
||||
<view v-if="canSubscribeClassReminder" class="panel">
|
||||
<button class="review-subscribe" @tap="subscribeClassReminder">🔔 订阅开课前 1 小时微信提醒</button>
|
||||
</view>
|
||||
<view v-if="canSubscribeReview" class="panel">
|
||||
<button class="review-subscribe" @tap="subscribeReview">{{ booking?.status === BookingStatus.COMPLETED ? '如果现在不评,24 小时后提醒我' : '订阅课后评价提醒' }}</button>
|
||||
</view>
|
||||
@@ -222,10 +225,35 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import ClassReviewForm from '../../components/ClassReviewForm.vue'
|
||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||
|
||||
import { requestSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||
import {
|
||||
requestSubscriptionMessage,
|
||||
requestBookingCancelSubscriptionMessage,
|
||||
requestClassReminderSubscriptionMessage,
|
||||
cacheSubscriptionMessageTemplateConfig,
|
||||
} from '../../utils/wechat-subscription'
|
||||
import { get } from '../../utils/request'
|
||||
import { cacheSubscriptionMessageTemplateConfig } from '../../utils/wechat-subscription'
|
||||
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
|
||||
|
||||
const canSubscribeClassReminder = computed(() => {
|
||||
if (!booking.value || booking.value.status !== BookingStatus.CONFIRMED) return false
|
||||
if (booking.value.userId !== userStore.user?.id) return false
|
||||
const slot = booking.value.timeSlot
|
||||
if (!slot) return false
|
||||
return !isSlotPast(slot.date, slot.startTime)
|
||||
})
|
||||
|
||||
async function subscribeClassReminder() {
|
||||
try {
|
||||
const results = await requestClassReminderSubscriptionMessage()
|
||||
uni.showToast({
|
||||
title: results.some((r) => r.result === 'accept') ? '上课提醒已开启' : '暂未开启提醒',
|
||||
icon: 'none',
|
||||
})
|
||||
} catch {
|
||||
uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeReview() {
|
||||
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
|
||||
}
|
||||
@@ -606,6 +634,12 @@ async function handleNoShow() {
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
try {
|
||||
await requestBookingCancelSubscriptionMessage()
|
||||
} catch (err: unknown) {
|
||||
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '取消预约',
|
||||
content: '确定要取消该预约?',
|
||||
|
||||
@@ -98,6 +98,7 @@ import DateSelector from '../../components/DateSelector.vue'
|
||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||
import SlotCard from '../../components/SlotCard.vue'
|
||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||
|
||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||
|
||||
@@ -368,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
|
||||
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
||||
if (!slot.myBookingId) return
|
||||
|
||||
try {
|
||||
await requestBookingCancelSubscriptionMessage()
|
||||
} catch (err: unknown) {
|
||||
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '取消预约',
|
||||
content: '确定要取消这个预约吗?',
|
||||
|
||||
@@ -154,6 +154,7 @@ import {
|
||||
} from '../../utils/booking-helpers'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
||||
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||
|
||||
type TabKey = 'upcoming' | 'history'
|
||||
|
||||
@@ -327,6 +328,12 @@ function goDetail(booking: BookingWithDetails) {
|
||||
}
|
||||
|
||||
async function handleCancel(booking: BookingWithDetails) {
|
||||
try {
|
||||
await requestBookingCancelSubscriptionMessage()
|
||||
} catch (err: unknown) {
|
||||
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||
}
|
||||
|
||||
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
||||
const timeLabel = startTime(booking)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:require-auth="loggedIn"
|
||||
@clear-cache="handleClearCache"
|
||||
@require-login="handleLogin"
|
||||
@open-notifications="showNotificationsModal = true"
|
||||
>
|
||||
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
||||
</ProfileMenu>
|
||||
@@ -25,6 +26,9 @@
|
||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
|
||||
</view>
|
||||
|
||||
<!-- Notification Settings Modal -->
|
||||
<SubscriptionSettingsModal v-model:visible="showNotificationsModal" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -40,11 +44,13 @@ import { getErrorMessage } from '../../utils/auth'
|
||||
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
|
||||
|
||||
const invite = useInviteStore()
|
||||
const userStore = useUserStore()
|
||||
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
|
||||
|
||||
const showNotificationsModal = ref(false)
|
||||
const activityRefreshKey = ref(0)
|
||||
const membershipNow = ref(Date.now())
|
||||
const loginLoading = ref(false)
|
||||
|
||||
@@ -6,9 +6,11 @@ import type {
|
||||
SubscriptionMessageRequestItem,
|
||||
SubscriptionMessageTemplate,
|
||||
SubscriptionMessageTemplateConfig,
|
||||
SubscriptionQuotaItem,
|
||||
SubscriptionQuotasResponse,
|
||||
UserProfileResponse,
|
||||
} from '@mp-pilates/shared'
|
||||
import { post } from './request'
|
||||
import { get, post } from './request'
|
||||
|
||||
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
||||
|
||||
@@ -163,11 +165,22 @@ function normalizeSubscribeResults(
|
||||
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
||||
}
|
||||
|
||||
export const SUBSCRIBE_BUNDLE_BOOKING: SubscriptionMessageScene[] = [
|
||||
SubscriptionMessageScene.BOOKING_CREATED,
|
||||
SubscriptionMessageScene.CLASS_REMINDER,
|
||||
SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||
]
|
||||
|
||||
export const SUBSCRIBE_BUNDLE_CANCEL: SubscriptionMessageScene[] = [
|
||||
SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||
SubscriptionMessageScene.CLASS_REMINDER,
|
||||
]
|
||||
|
||||
/**
|
||||
* 在当前调用栈同步调起 `uni.requestSubscribeMessage`。
|
||||
* 在当前调用栈同步调起 `uni.requestSubscribeMessage`(支持多场景组合打包,最多 3 个模板)。
|
||||
* 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。
|
||||
*/
|
||||
export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
||||
export function requestSubscriptionBundle(scenes: SubscriptionMessageScene[]): Promise<SubscriptionMessageRequestItem[]> {
|
||||
if (!isMpWeixin()) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
@@ -177,15 +190,28 @@ export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Pro
|
||||
return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试'))
|
||||
}
|
||||
|
||||
const templates = getTemplatesByScene(config, scene)
|
||||
// 按场景收集模板,去重 templateId,微信单次最多支持 3 个模板
|
||||
const templateMap = new Map<string, SubscriptionMessageTemplate>()
|
||||
for (const scene of scenes) {
|
||||
const list = getTemplatesByScene(config, scene)
|
||||
for (const tpl of list) {
|
||||
if (tpl.templateId && !templateMap.has(tpl.templateId)) {
|
||||
templateMap.set(tpl.templateId, tpl)
|
||||
}
|
||||
if (templateMap.size >= 3) break
|
||||
}
|
||||
if (templateMap.size >= 3) break
|
||||
}
|
||||
|
||||
const templates = Array.from(templateMap.values())
|
||||
if (templates.length === 0) {
|
||||
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
|
||||
console.error('[subscribe] no templates matched bundle', stringifyDebugPayload({ scenes, config, debugContext: getSubscribeDebugContext() }))
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
const templateIds = templates.map((item) => item.templateId)
|
||||
const debugContext = getSubscribeDebugContext()
|
||||
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
|
||||
console.log('[subscribe] requestSubscriptionBundle:start', stringifyDebugPayload({ scenes, templateIds, templates, debugContext }))
|
||||
|
||||
return new Promise<SubscriptionMessageRequestItem[]>((resolve, reject) => {
|
||||
uni.requestSubscribeMessage({
|
||||
@@ -193,22 +219,78 @@ export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Pro
|
||||
success: (res) => {
|
||||
const response = res as RequestSubscribeMessageSuccess
|
||||
const requests = normalizeSubscribeResults(templates, response)
|
||||
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response, templateIds, debugContext }))
|
||||
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result: response, requests, templateIds, debugContext }))
|
||||
console.log('[subscribe] requestSubscriptionBundle:success', stringifyDebugPayload({ scenes, response, templateIds, debugContext }))
|
||||
console.log('[subscribe] requestSubscriptionBundle:normalized', stringifyDebugPayload({ scenes, result: response, requests, templateIds, debugContext }))
|
||||
void reportResults(requests)
|
||||
.then(() => resolve(requests))
|
||||
.catch(reject)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
|
||||
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
|
||||
console.error('[subscribe] requestSubscriptionBundle:fail', stringifyDebugPayload({ scenes, error: err, templateIds, debugContext }))
|
||||
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scenes[0], templateIds))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
||||
return requestSubscriptionBundle([scene])
|
||||
}
|
||||
|
||||
/**
|
||||
* 约课三合一组合授权:包含约课成功确认、开课前1小时提醒、课程取消通知
|
||||
* 一次点击,三个场景额度同时 +1!
|
||||
*/
|
||||
export function requestBookingBundleSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_BOOKING)
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容原有命名,直接升级为约课三合一组合授权
|
||||
*/
|
||||
export function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||
return requestBookingBundleSubscriptionMessage()
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消预约时的组合授权:课程取消通知 + 下次上课提醒
|
||||
*/
|
||||
export function requestBookingCancelSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_CANCEL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上课前1小时提醒单项授权
|
||||
*/
|
||||
export function requestClassReminderSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||
return requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REMINDER)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的各订阅场景额度水位
|
||||
*/
|
||||
export async function fetchUserSubscriptionQuotas(): Promise<SubscriptionQuotaItem[]> {
|
||||
try {
|
||||
const res = await get<SubscriptionQuotasResponse>('/user/subscription-messages/quotas')
|
||||
return res.quotas || []
|
||||
} catch (error) {
|
||||
console.warn('[subscribe] fetchUserSubscriptionQuotas failed', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导打开微信系统设置页,供用户恢复通知授权
|
||||
*/
|
||||
export function openSubscribeSettings(): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
uni.openSetting({
|
||||
success: (res) => {
|
||||
resolve(!!res.authSetting)
|
||||
},
|
||||
fail: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
|
||||
|
||||
Reference in New Issue
Block a user