feat: 支持分享邀请好友功能

This commit is contained in:
richarjiang
2026-04-19 14:12:25 +08:00
parent b02f38dcc7
commit 9575210b06
34 changed files with 1101 additions and 8 deletions

View File

@@ -49,6 +49,7 @@ const props = defineProps<{
requireAuth?: boolean
activeMembershipCount?: number
upcomingBookingCount?: number
inviteShareEligible?: boolean
}>()
const emit = defineEmits<{
@@ -81,6 +82,15 @@ const menuItems = computed<MenuItem[]>(() => {
badge: bookingBadge,
requireAuth: true,
},
...(props.inviteShareEligible
? [{
key: 'invite',
type: 'item' as const,
title: '邀请好友',
path: '/pages/profile/invite',
requireAuth: true,
}]
: []),
{
key: 'info',
type: 'item',
@@ -226,6 +236,34 @@ function handleTap(item: MenuItem) {
}
}
&--invite {
background: rgba(255, 122, 69, 0.12);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid #ff7a45;
border-radius: 50%;
position: absolute;
top: 12rpx;
left: 14rpx;
box-sizing: border-box;
box-shadow: 16rpx 8rpx 0 -2rpx rgba(255, 122, 69, 0.95);
}
&::after {
content: '';
position: absolute;
width: 22rpx;
height: 12rpx;
border: 2.5rpx solid #ff7a45;
border-top: none;
border-radius: 0 0 14rpx 14rpx;
left: 17rpx;
bottom: 13rpx;
box-sizing: border-box;
}
}
// 个人信息 — 人形(圆 + 肩弧)
&--info {
background: rgba($brand-color, 0.06);

View File

@@ -51,6 +51,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/invite",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/teacher/detail",
"style": {

View File

@@ -332,8 +332,10 @@ async function doPurchase() {
uni.showLoading({ title: '创建订单...' })
try {
const inviterId = uni.getStorageSync('invite_inviter_id') as string
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
inviterId: isTrial.value && inviterId ? inviterId : undefined,
})
uni.hideLoading()

View File

@@ -13,6 +13,7 @@
:require-auth="loggedIn"
:active-membership-count="activeMembershipCount"
:upcoming-booking-count="upcomingBookingCount"
:invite-share-eligible="!!user?.inviteShareEligible"
@clear-cache="handleClearCache"
@require-login="handleLogin"
/>

View File

@@ -0,0 +1,504 @@
<template>
<view class="invite-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="邀请好友" show-back />
<scroll-view class="invite-scroll" scroll-y>
<view class="hero-card">
<view class="hero-glow hero-glow--one" />
<view class="hero-glow hero-glow--two" />
<text class="hero-badge">会员专享裂变活动</text>
<text class="hero-title"> 3 位好友体验并核销</text>
<text class="hero-subtitle">好友购买体验课并完成上课后会员卡立即奖励 1 节正课次数</text>
<view class="hero-stats">
<view class="hero-stat">
<text class="hero-stat-value">{{ summary?.qualifiedInviteCount ?? 0 }}</text>
<text class="hero-stat-label">已完成邀请</text>
</view>
<view class="hero-stat hero-stat--accent">
<text class="hero-stat-value">{{ summary?.rewardedTimes ?? 0 }}</text>
<text class="hero-stat-label">已得奖励</text>
</view>
<view class="hero-stat">
<text class="hero-stat-value">{{ summary?.nextRewardRemainingCount ?? 3 }}</text>
<text class="hero-stat-label">距下次奖励</text>
</view>
</view>
<view class="progress-shell">
<view class="progress-track">
<view class="progress-fill" :style="{ width: progressWidth }" />
</view>
<text class="progress-caption">本轮进度 {{ summary?.currentCycleQualifiedCount ?? 0 }}/{{ summary?.rewardRuleInvitesRequired ?? 3 }}</text>
</view>
<button class="share-btn" open-type="share">
立即邀请好友
</button>
<text class="share-hint">分享后新用户登录并购买体验课即可自动绑定邀请关系</text>
</view>
<view class="steps-card">
<text class="section-title">活动规则</text>
<view v-for="item in ruleSteps" :key="item.title" class="step-item">
<view class="step-index">{{ item.index }}</view>
<view class="step-body">
<text class="step-title">{{ item.title }}</text>
<text class="step-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view class="referrals-card">
<view class="section-head">
<text class="section-title">邀请进度</text>
<text class="section-meta">待完成 {{ summary?.pendingInviteCount ?? 0 }} </text>
</view>
<view v-if="summary?.referrals?.length" class="referral-list">
<view v-for="item in summary.referrals" :key="item.id" class="referral-item">
<image v-if="item.inviteeAvatarUrl" class="referral-avatar" :src="item.inviteeAvatarUrl" mode="aspectFill" />
<view v-else class="referral-avatar referral-avatar--placeholder"></view>
<view class="referral-main">
<text class="referral-name">{{ item.inviteeNickname || '新好友' }}</text>
<text class="referral-time">邀请于 {{ formatDateTime(item.invitedAt) }}</text>
</view>
<text class="referral-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
</view>
</view>
<view v-else class="empty-block">
<text class="empty-title">还没有邀请记录</text>
<text class="empty-desc">先分享给 3 位好友完成一次体验闭环就会在这里点亮进度</text>
</view>
</view>
<view class="reward-card">
<view class="section-head">
<text class="section-title">奖励记录</text>
<text class="section-meta">累计 {{ summary?.rewardedTimes ?? 0 }} </text>
</view>
<view v-if="summary?.rewardGrants?.length" class="reward-list">
<view v-for="item in summary.rewardGrants" :key="item.id" class="reward-item">
<text class="reward-item-title">完成 {{ item.qualifiedReferralCount }} 位好友核销</text>
<text class="reward-item-time">{{ formatDateTime(item.grantedAt) }}</text>
<text class="reward-item-tag">+{{ item.rewardTimes }} </text>
</view>
</view>
<view v-else class="empty-block empty-block--warm">
<text class="empty-title">还未获得奖励</text>
<text class="empty-desc"> 3 位好友完成体验核销系统自动增加 1 节真实会员课次</text>
</view>
</view>
<view class="bottom-space" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { InviteReferralStatus } from '@mp-pilates/shared'
import { useInviteStore } from '../../stores/invite'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { formatDateTime } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const inviteStore = useInviteStore()
const userStore = useUserStore()
const navBarHeight = ref('64px')
const summary = computed(() => inviteStore.activity)
const progressWidth = computed(() => {
const current = summary.value?.currentCycleQualifiedCount ?? 0
const total = summary.value?.rewardRuleInvitesRequired ?? 3
return `${Math.min(100, (current / total) * 100)}%`
})
const ruleSteps = [
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
]
onLoad((query) => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
if (inviterId) {
uni.setStorageSync('invite_inviter_id', inviterId)
}
})
onShow(async () => {
if (!userStore.loggedIn) {
return
}
await Promise.all([
userStore.fetchProfile(),
inviteStore.fetchActivity(),
])
})
onShareAppMessage(() => ({
title: '邀 3 位好友体验核销,立得 1 节会员正课',
path: summary.value?.sharePath || `/pages/profile/invite?inviterId=${userStore.user?.id || ''}`,
imageUrl: '',
}))
onShareTimeline(() => ({
title: '邀 3 位好友体验核销,立得 1 节会员正课',
query: `inviterId=${userStore.user?.id || ''}`,
}))
function statusLabel(status: InviteReferralStatus): string {
const map: Record<InviteReferralStatus, string> = {
[InviteReferralStatus.REGISTERED]: '已注册',
[InviteReferralStatus.TRIAL_PURCHASED]: '已购体验课',
[InviteReferralStatus.QUALIFIED]: '已完成核销',
}
return map[status]
}
function statusClass(status: InviteReferralStatus): string {
if (status === InviteReferralStatus.QUALIFIED) return 'referral-status--done'
if (status === InviteReferralStatus.TRIAL_PURCHASED) return 'referral-status--paid'
return 'referral-status--registered'
}
</script>
<style lang="scss" scoped>
.invite-page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(255, 142, 83, 0.28), transparent 34%),
radial-gradient(circle at top right, rgba(255, 214, 102, 0.34), transparent 26%),
linear-gradient(180deg, #fff5db 0%, #ffe7ea 30%, #fef7ff 100%);
}
.invite-scroll {
height: 100vh;
padding: 24rpx;
box-sizing: border-box;
}
.hero-card,
.steps-card,
.referrals-card,
.reward-card {
position: relative;
overflow: hidden;
border-radius: 36rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 18rpx 50rpx rgba(157, 70, 42, 0.08);
}
.hero-card {
background: linear-gradient(135deg, #ff7a45 0%, #ff4d6d 48%, #ffb347 100%);
color: #fff;
}
.hero-glow {
position: absolute;
border-radius: 50%;
opacity: 0.28;
background: rgba(255, 255, 255, 0.5);
}
.hero-glow--one {
width: 260rpx;
height: 260rpx;
top: -90rpx;
right: -40rpx;
}
.hero-glow--two {
width: 180rpx;
height: 180rpx;
bottom: -50rpx;
left: -40rpx;
}
.hero-badge {
display: inline-flex;
align-self: flex-start;
padding: 10rpx 18rpx;
background: rgba(255, 255, 255, 0.18);
border-radius: 999rpx;
font-size: 22rpx;
margin-bottom: 18rpx;
}
.hero-title {
display: block;
font-size: 52rpx;
font-weight: 700;
line-height: 1.18;
}
.hero-subtitle {
display: block;
margin-top: 18rpx;
font-size: 26rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.92);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18rpx;
margin-top: 28rpx;
}
.hero-stat {
padding: 24rpx 18rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(10rpx);
}
.hero-stat--accent {
background: rgba(75, 16, 16, 0.22);
}
.hero-stat-value {
display: block;
font-size: 46rpx;
font-weight: 700;
}
.hero-stat-label {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.86);
}
.progress-shell {
margin-top: 28rpx;
}
.progress-track {
height: 20rpx;
border-radius: 999rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.25);
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #fff7ad 0%, #ffffff 100%);
}
.progress-caption {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.88);
}
.share-btn {
margin-top: 28rpx;
height: 96rpx;
line-height: 96rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 700;
color: #ff5a3c;
background: linear-gradient(90deg, #fff7e4 0%, #ffffff 100%);
border: none;
}
.share-btn::after {
border: none;
}
.share-hint {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.82);
}
.steps-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 234, 0.92));
}
.section-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #30201a;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.section-meta {
font-size: 22rpx;
color: #9b6b55;
}
.step-item {
display: flex;
gap: 18rpx;
align-items: flex-start;
padding: 22rpx 0;
border-bottom: 1rpx solid rgba(214, 171, 134, 0.2);
}
.step-item:last-child {
border-bottom: none;
}
.step-index {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
text-align: center;
line-height: 64rpx;
font-size: 24rpx;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff8f5a 0%, #ff4d6d 100%);
}
.step-body {
flex: 1;
}
.step-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #36231d;
}
.step-desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.7;
color: #7b5d52;
}
.referrals-card {
background: linear-gradient(180deg, #ffffff 0%, #fff7fb 100%);
}
.reward-card {
background: linear-gradient(180deg, #fffdf5 0%, #fff2dc 100%);
}
.referral-item,
.reward-item {
display: flex;
align-items: center;
padding: 22rpx 0;
border-bottom: 1rpx solid rgba(221, 196, 177, 0.35);
}
.referral-item:last-child,
.reward-item:last-child {
border-bottom: none;
}
.referral-avatar {
width: 78rpx;
height: 78rpx;
border-radius: 50%;
margin-right: 18rpx;
background: #ffd9c8;
}
.referral-avatar--placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: #ff6f3c;
}
.referral-main {
flex: 1;
}
.referral-name,
.reward-item-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #31211a;
}
.referral-time,
.reward-item-time {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #8b6d62;
}
.referral-status,
.reward-item-tag {
padding: 12rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
}
.referral-status--registered {
color: #9c5e2f;
background: #fff0de;
}
.referral-status--paid {
color: #c44f1f;
background: #ffe0d1;
}
.referral-status--done,
.reward-item-tag {
color: #0f7a53;
background: #dff7ea;
}
.empty-block {
padding: 36rpx 0 10rpx;
text-align: center;
}
.empty-block--warm {
padding-bottom: 0;
}
.empty-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #5f4337;
}
.empty-desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.8;
color: #9c7d70;
}
.bottom-space {
height: 48rpx;
}
</style>

View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { InviteActivitySummary } from '@mp-pilates/shared'
import { get } from '../utils/request'
export const useInviteStore = defineStore('invite', () => {
const activity = ref<InviteActivitySummary | null>(null)
const loading = ref(false)
async function fetchActivity() {
loading.value = true
try {
activity.value = await get<InviteActivitySummary>('/invite/activity')
return activity.value
} finally {
loading.value = false
}
}
return {
activity,
loading,
fetchActivity,
}
})

View File

@@ -28,6 +28,7 @@ export const useUserStore = defineStore('user', () => {
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
// Actions
async function login() {
@@ -124,6 +125,7 @@ export const useUserStore = defineStore('user', () => {
isAdmin,
activeMemberships,
hasValidMembership,
inviteShareEligible,
login,
loginWithSetup,
fetchProfile,

View File

@@ -54,6 +54,8 @@ export function getErrorMessage(err: unknown, fallback: string): string {
}
export async function wxLogin(): Promise<LoginResponse> {
const inviterId = uni.getStorageSync('invite_inviter_id') as string
await ensurePrivacyAuthorization()
return new Promise((resolve, reject) => {
@@ -72,8 +74,12 @@ export async function wxLogin(): Promise<LoginResponse> {
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
const result = await post<LoginResponse>('/auth/login', {
code: loginRes.code,
inviterId: inviterId || undefined,
})
uni.setStorageSync('token', result.token)
if (result.isNewUser && inviterId) {
uni.removeStorageSync('invite_inviter_id')
}
resolve(result)
} catch (err) {
reject(err)