feat: 优化微信订阅消息体系并新增约课取消通知与开课前1小时提醒

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-10 16:20:13 +08:00
parent d32f592e54
commit 3e049d2c1d
26 changed files with 1268 additions and 44 deletions

View File

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

View File

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

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