perf: 支持约课以及消息推送能力
This commit is contained in:
@@ -105,13 +105,13 @@
|
||||
<view class="btn-outline" @tap="handleCancel">
|
||||
<text class="btn-outline-text">取消</text>
|
||||
</view>
|
||||
<view
|
||||
<button
|
||||
class="btn-confirm"
|
||||
:class="{ disabled: !selectedMembershipId }"
|
||||
@tap="handleConfirm"
|
||||
>
|
||||
<text class="btn-confirm-text">确认预约</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -120,6 +120,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
@@ -134,6 +135,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const selectedMembershipId = ref<string>('')
|
||||
const requestingSubscribe = ref(false)
|
||||
|
||||
// Auto-select the first membership when popup opens or memberships list changes
|
||||
watch(
|
||||
@@ -150,8 +152,22 @@ const selectedMembership = computed(() =>
|
||||
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
||||
)
|
||||
|
||||
function handleConfirm() {
|
||||
async function handleConfirm() {
|
||||
if (!props.timeSlot || !selectedMembershipId.value) return
|
||||
|
||||
if (requestingSubscribe.value) return
|
||||
requestingSubscribe.value = true
|
||||
|
||||
try {
|
||||
await requestBookingCreatedSubscriptionMessage()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '订阅消息授权失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
return
|
||||
} finally {
|
||||
requestingSubscribe.value = false
|
||||
}
|
||||
|
||||
emit('confirm', {
|
||||
timeSlotId: props.timeSlot.id,
|
||||
membershipId: selectedMembershipId.value,
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { getErrorMessage } from '../utils/auth'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scroll-to-card-shop'): void
|
||||
@@ -71,8 +72,8 @@ async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.loginWithSetup()
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -239,6 +239,7 @@ import type {
|
||||
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { isSlotPast } from '../../utils/format'
|
||||
import {
|
||||
@@ -357,8 +358,8 @@ async function handleSlotBook() {
|
||||
if (!isNewUser) {
|
||||
handleSlotBook()
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -87,6 +87,7 @@ import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pila
|
||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
@@ -208,8 +209,8 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
||||
if (!isNewUser) {
|
||||
onBookTap(slot)
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -182,10 +182,12 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { get, post } from '../../utils/request'
|
||||
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -287,8 +289,8 @@ async function handleBuy() {
|
||||
if (!isNewUser) {
|
||||
handleBuy()
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -334,6 +336,7 @@ async function doPurchase() {
|
||||
})
|
||||
|
||||
// Payment succeeded — refresh memberships then navigate
|
||||
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||||
await userStore.fetchMemberships()
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -208,12 +208,14 @@ import {
|
||||
FlashSaleOrderStatus,
|
||||
} from '@mp-pilates/shared'
|
||||
import type { FlashSaleDetail } from '@mp-pilates/shared'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../../utils/format'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { useFlashSaleStore } from '../../stores/flash-sale'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { post } from '../../utils/request'
|
||||
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const flashSaleStore = useFlashSaleStore()
|
||||
@@ -343,8 +345,8 @@ async function handleAction() {
|
||||
if (!isNewUser) {
|
||||
await loadDetail() // refresh participation status
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -395,6 +397,7 @@ async function doPurchase() {
|
||||
})
|
||||
})
|
||||
|
||||
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
||||
uni.showToast({ title: '抢购成功!', icon: 'success' })
|
||||
await userStore.fetchMemberships()
|
||||
await loadDetail() // refresh status
|
||||
|
||||
@@ -24,6 +24,7 @@ import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
@@ -72,8 +73,8 @@ async function handleLogin() {
|
||||
if (!isNewUser) {
|
||||
await userStore.fetchStats()
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
||||
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
||||
import { get, put } from '../utils/request'
|
||||
import { ROUTES } from '../utils/routes'
|
||||
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// State
|
||||
@@ -59,6 +60,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
if (!isLoggedIn()) return
|
||||
try {
|
||||
user.value = await get<UserProfileResponse>('/user/profile')
|
||||
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
||||
} catch (err) {
|
||||
console.error('Fetch profile failed:', err)
|
||||
}
|
||||
@@ -97,6 +99,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
function logout() {
|
||||
authLogout()
|
||||
resetSubscriptionMessageTemplateCache()
|
||||
token.value = ''
|
||||
user.value = null
|
||||
stats.value = null
|
||||
|
||||
@@ -7,13 +7,66 @@ interface LoginResponse {
|
||||
readonly isNewUser: boolean
|
||||
}
|
||||
|
||||
interface UniErrorLike {
|
||||
readonly errMsg?: string
|
||||
}
|
||||
|
||||
interface WechatPrivacyApi {
|
||||
requirePrivacyAuthorize?: (options: {
|
||||
success: () => void
|
||||
fail: (err: UniErrorLike) => void
|
||||
}) => void
|
||||
}
|
||||
|
||||
function buildPrivacyError(err?: UniErrorLike): Error {
|
||||
const errMsg = err?.errMsg || ''
|
||||
|
||||
if (errMsg.includes('cancel') || errMsg.includes('deny') || errMsg.includes('disagree')) {
|
||||
return new Error('请先同意隐私保护指引')
|
||||
}
|
||||
|
||||
return new Error('隐私授权失败,请重试')
|
||||
}
|
||||
|
||||
async function ensurePrivacyAuthorization(): Promise<void> {
|
||||
// #ifdef MP-WEIXIN
|
||||
const wechat = (globalThis as typeof globalThis & { wx?: WechatPrivacyApi }).wx
|
||||
if (!wechat || typeof wechat.requirePrivacyAuthorize !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const requirePrivacyAuthorize = wechat.requirePrivacyAuthorize
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
requirePrivacyAuthorize({
|
||||
success: () => resolve(),
|
||||
fail: (err: UniErrorLike) => reject(buildPrivacyError(err)),
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err instanceof Error && err.message) {
|
||||
return err.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function wxLogin(): Promise<LoginResponse> {
|
||||
await ensurePrivacyAuthorization()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Step 1:静默登录,获取 code
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
try {
|
||||
if (!loginRes.code) {
|
||||
reject(new Error('微信登录失败,请重试'))
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: 发送登录请求
|
||||
// 注:uni.getUserProfile 已被微信废弃(基础库 2.27.1+),
|
||||
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
||||
|
||||
188
packages/app/src/utils/wechat-subscription.ts
Normal file
188
packages/app/src/utils/wechat-subscription.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
SubscriptionMessageScene,
|
||||
} from '@mp-pilates/shared'
|
||||
import type {
|
||||
ReportSubscriptionMessageRequestDto,
|
||||
SubscriptionMessageRequestItem,
|
||||
SubscriptionMessageTemplate,
|
||||
SubscriptionMessageTemplateConfig,
|
||||
} from '@mp-pilates/shared'
|
||||
import { post } from './request'
|
||||
|
||||
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
||||
|
||||
type RequestSubscribeMessageSuccess = Record<string, TemplateResult | undefined> & {
|
||||
errMsg?: string
|
||||
}
|
||||
|
||||
interface RequestSubscribeMessageFail {
|
||||
errMsg?: string
|
||||
errCode?: number | null
|
||||
}
|
||||
|
||||
function stringifyDebugPayload(payload: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return String(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscribeDebugContext() {
|
||||
try {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const host = systemInfo.host as { env?: string } | string | undefined
|
||||
return {
|
||||
platform: systemInfo.platform,
|
||||
hostEnv: typeof host === 'object' && host ? host.env : undefined,
|
||||
app: systemInfo.appName,
|
||||
system: systemInfo.system,
|
||||
language: systemInfo.language,
|
||||
version: systemInfo.version,
|
||||
SDKVersion: systemInfo.SDKVersion,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
platform: 'unknown',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSubscribeError(err: RequestSubscribeMessageFail, scene: SubscriptionMessageScene, templateIds: string[]): Error {
|
||||
const debugContext = getSubscribeDebugContext()
|
||||
const rawMessage = (err.errMsg || '').trim()
|
||||
|
||||
if (!rawMessage && debugContext.platform === 'devtools') {
|
||||
return new Error(
|
||||
`开发者工具当前环境不支持订阅消息唤起。请退出游客模式,使用已登录的微信开发者工具并在真机中重试。调试信息: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
|
||||
)
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`订阅消息授权失败: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
|
||||
)
|
||||
}
|
||||
|
||||
const TEMPLATE_CONFIG_STORAGE_KEY = 'subscriptionMessageTemplateConfig'
|
||||
|
||||
let cachedConfig: SubscriptionMessageTemplateConfig | null = null
|
||||
|
||||
function isMpWeixin(): boolean {
|
||||
// #ifdef MP-WEIXIN
|
||||
return true
|
||||
// #endif
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeResult(result?: TemplateResult): SubscriptionMessageRequestItem['result'] | null {
|
||||
if (result === 'accept' || result === 'reject' || result === 'ban' || result === 'filter') {
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig
|
||||
}
|
||||
|
||||
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
|
||||
if (!stored || !Array.isArray(stored.templates)) {
|
||||
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
|
||||
}
|
||||
|
||||
const config: SubscriptionMessageTemplateConfig = {
|
||||
templates: stored.templates.filter((item) => item.templateId),
|
||||
}
|
||||
cachedConfig = config
|
||||
return config
|
||||
}
|
||||
|
||||
export function cacheSubscriptionMessageTemplateConfig(config: SubscriptionMessageTemplateConfig): SubscriptionMessageTemplateConfig {
|
||||
const normalized: SubscriptionMessageTemplateConfig = {
|
||||
templates: config.templates.filter((item) => item.templateId),
|
||||
}
|
||||
cachedConfig = normalized
|
||||
uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getTemplatesByScene(
|
||||
config: SubscriptionMessageTemplateConfig,
|
||||
scene: SubscriptionMessageScene,
|
||||
): SubscriptionMessageTemplate[] {
|
||||
return config.templates.filter((item) => item.scene === scene && item.templateId)
|
||||
}
|
||||
|
||||
async function reportResults(requests: SubscriptionMessageRequestItem[]): Promise<void> {
|
||||
if (requests.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: ReportSubscriptionMessageRequestDto = { requests }
|
||||
await post('/user/subscription-messages/report', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export async function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
||||
if (!isMpWeixin()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const config = await fetchTemplateConfig()
|
||||
const templates = getTemplatesByScene(config, scene)
|
||||
if (templates.length === 0) {
|
||||
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
|
||||
return []
|
||||
}
|
||||
|
||||
const templateIds = templates.map((item) => item.templateId)
|
||||
const debugContext = getSubscribeDebugContext()
|
||||
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
|
||||
|
||||
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
|
||||
uni.requestSubscribeMessage({
|
||||
tmplIds: templateIds,
|
||||
success: (res) => {
|
||||
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response: res, templateIds, debugContext }))
|
||||
resolve(res as RequestSubscribeMessageSuccess)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
|
||||
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const requests = templates
|
||||
.map<SubscriptionMessageRequestItem | null>((item) => {
|
||||
const normalized = normalizeResult(result[item.templateId])
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
templateId: item.templateId,
|
||||
scene: item.scene,
|
||||
result: normalized,
|
||||
}
|
||||
})
|
||||
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
||||
|
||||
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result, requests, templateIds, debugContext }))
|
||||
|
||||
await reportResults(requests)
|
||||
return requests
|
||||
}
|
||||
|
||||
export async function requestOrderPaidSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||
}
|
||||
|
||||
export async function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||
}
|
||||
|
||||
export function resetSubscriptionMessageTemplateCache(): void {
|
||||
cachedConfig = null
|
||||
uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY)
|
||||
}
|
||||
Reference in New Issue
Block a user