mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-11-13 12:28:50 +08:00
feat(paid-member): 添加付费会员功能模块
- 新增付费会员API接口层,支持会员查询、下单、记录及报价 - 新增付费会员页面,展示权益、推荐商品及购买流程 - 新增付费会员购买记录页面,支持订单状态查看与继续支付 - 新增 PaidMemberPrice 组件用于商品列表和详情页展示会员价 - 个人中心页添加付费会员入口,支持配置化展示标题与背景 - 商品详情页集成会员价展示,支持秒杀和拼团场景下隐藏 - 商品列表组件集成会员价展示,支持批量请求与防竞态 - 支付页适配付费会员支付流程,支持H5轮询与结果回调 - 订单确认页优惠文案更新为"活动优惠(含会员优惠)" - pages.json 新增付费会员及购买记录页面路由配置 - README 更新升级计划,按时间倒序补充已完成功能明细
This commit is contained in:
79
pages/mine/paidMember.vue
Normal file
79
pages/mine/paidMember.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view class="paid-page">
|
||||
<view v-if="loading && !data.config" class="empty">加载中…</view>
|
||||
<view v-else-if="error && !data.config" class="empty" @click="load">加载失败,点击重试</view>
|
||||
<template v-else-if="data.config">
|
||||
<view v-if="error" class="poll-message" @click="load">刷新失败,点击重试</view>
|
||||
<view class="member-card" :style="cardStyle">
|
||||
<image v-if="data.config.backgroundType === 'IMAGE' && data.config.backgroundImage" class="card-bg" :src="data.config.backgroundImage" mode="aspectFill" />
|
||||
<view class="card-content"><view class="card-title"><image v-if="data.config.icon" class="member-icon" :src="data.config.icon" mode="aspectFit" /><text>{{ data.config.name }}</text></view><text class="member-status">{{ data.active ? '会员有效期至 ' + data.endTime : data.status === 'EXPIRED' ? '会员已到期' : '开通会员,享专属权益' }}</text><text v-if="data.status !== 'UNOPENED'" class="saved">累计已优惠 ¥{{ money(data.savedAmount) }}</text></view>
|
||||
</view>
|
||||
<view v-if="pollMessage" class="poll-message" @click="checkPayment">{{ pollMessage }}</view>
|
||||
<view class="purchase-card"><view><text class="price">¥{{ money(data.config.price) }}</text><text class="period"> / {{ data.config.months }}个月</text></view><text class="hint">{{ data.active ? '续费将从原到期时间顺延,续费权益在新周期开始后生效' : '支付成功后开通,按自然月计算有效期' }}</text></view>
|
||||
<view class="section"><view class="section-heading"><text>专属权益</text><text class="record-link" @click="goRecords">购买记录 ›</text></view>
|
||||
<view class="rights-grid"><view v-for="b in displayedBenefits" :key="b.id" class="right-item" @click="detail = b"><image v-if="b.benefitLogo" :src="b.benefitLogo" mode="aspectFit" /><u-icon v-else name="gift" size="28" color="#ae8245" /><text>{{ b.benefitName }}</text></view></view><text v-if="!displayedBenefits.length" class="hint">{{ data.active ? '当前暂无关联权益' : '开通后按会员配置享有权益' }}</text>
|
||||
<view class="discount-note"><text>会员专享商品折扣</text><text>优惠由平台提供,限适用商家及商品。实际优惠以结算页为准。</text></view>
|
||||
</view>
|
||||
<view v-if="data.recommendedGoods?.length" class="section"><view class="section-heading">为你推荐</view><view class="goods-grid"><view v-for="g in data.recommendedGoods" :key="g.id" class="goods-card" @click="goGoods(g)"><image class="goods-cover" :src="g.thumbnail" mode="aspectFit" /><text class="goods-name">{{ g.goodsName }}</text><text class="goods-price">¥{{ money(g.price) }}</text><view v-if="quotes[g.id]" class="member-price"><image v-if="quotes[g.id].label" class="member-price-label" :src="quotes[g.id].label" mode="aspectFit" /><text>会员价 ¥{{ money(quotes[g.id].price) }}</text></view></view></view></view>
|
||||
<view v-if="data.config.description" class="section"><view class="section-heading">购买说明</view><rich-text :nodes="data.config.description" /></view>
|
||||
<view class="purchase-footer"><view class="agreement-row" @click="agreed = !agreed"><u-icon :name="agreed ? 'checkmark-circle-fill' : 'checkmark-circle'" size="17" color="#a78149" /><text>我已阅读并同意</text><text class="agreement-link" @click.stop="showAgreement = true">《付费会员协议》</text></view><button :disabled="!data.config.enabled || buying || !agreed" :loading="buying" class="buy-button" @click="buy">{{ !data.config.enabled ? '暂未开放购买和续费' : data.active ? '立即续费' : '立即开通' }}</button></view>
|
||||
</template>
|
||||
<view v-if="detail || showAgreement" class="overlay"><view class="mask" @click="closeDetail" @touchmove.stop.prevent /><view class="sheet"><text class="sheet-title">{{ detail?.benefitName || '付费会员协议' }}</text><scroll-view scroll-y class="sheet-scroll"><rich-text :nodes="detail ? detail.benefitDesc || '暂无说明' : data.config?.agreement || '请联系平台查看会员协议'" /></scroll-view><button class="close-button" @click="closeDetail">关闭</button></view></view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getPaidMember, createPaidMemberOrder, getPaidMemberOrder, getPaidMemberQuotes } from '@/api/paid-member'
|
||||
import { isLogin, navigateToLogin } from '@/utils/filters'
|
||||
const data=ref({}), quotes=ref({}), loading=ref(false), error=ref(false), agreed=ref(false), buying=ref(false), detail=ref(null), showAgreement=ref(false), pollMessage=ref('')
|
||||
let request=0, owner='', purchaseRequest='', paymentSn='', timer=null, pollId=0, pollCount=0
|
||||
const money=value=>(Number(value)||0).toFixed(2)
|
||||
const cardStyle=computed(()=>({backgroundColor:data.value.config?.backgroundColor || '#51443b',color:data.value.config?.fontColor || '#fff1d8'}))
|
||||
const displayedBenefits=computed(()=>data.value.active ? data.value.benefits || [] : data.value.offeredBenefits || [])
|
||||
function closeDetail(){detail.value=null;showAgreement.value=false}
|
||||
function goRecords(){uni.navigateTo({url:'/pages/mine/paidMemberRecords'})}
|
||||
function goGoods(g){uni.navigateTo({url:`/pages/product/goods?id=${g.id}&goodsId=${g.goodsId}`})}
|
||||
async function load(){
|
||||
const memberId=String(isLogin()?.id || '')
|
||||
if(!memberId){request++;stopPolling();owner='';data.value={};quotes.value={};loading.value=false;agreed.value=false;purchaseRequest='';paymentSn='';pollMessage.value='';navigateToLogin();uni.stopPullDownRefresh();return}
|
||||
if(owner!==memberId){if(owner){stopPolling();paymentSn='';pollMessage.value=''}owner=memberId;data.value={};quotes.value={};agreed.value=false;purchaseRequest='';detail.value=null}
|
||||
const id=++request;loading.value=true;error.value=false
|
||||
try{
|
||||
const res=await getPaidMember()
|
||||
if(id!==request || String(isLogin()?.id||'')!==memberId)return
|
||||
if(!res.data?.success)throw new Error('load')
|
||||
if(data.value.configVersion !== res.data.result?.configVersion) agreed.value=false
|
||||
data.value=res.data.result||{};quotes.value={}
|
||||
const ids=(data.value.recommendedGoods||[]).map(g=>g.id)
|
||||
const results=await Promise.all(Array.from({length:Math.ceil(ids.length/100)},(_,i)=>getPaidMemberQuotes(ids.slice(i*100,i*100+100))))
|
||||
if(id===request && String(isLogin()?.id||'')===memberId) quotes.value=Object.assign({},...results.filter(q=>q.data?.success).map(q=>q.data.result||{}))
|
||||
}catch{if(id===request)error.value=true}finally{if(id===request)loading.value=false;uni.stopPullDownRefresh()}
|
||||
}
|
||||
async function buy(){
|
||||
if(buying.value || !agreed.value || !data.value.config?.enabled)return
|
||||
buying.value=true
|
||||
const memberId=owner
|
||||
purchaseRequest=purchaseRequest || 'pm_'+Date.now().toString(36)+'_'+Math.random().toString(36).slice(2)
|
||||
try{
|
||||
const res=await createPaidMemberOrder({requestId:purchaseRequest,configVersion:data.value.configVersion,agreed:true})
|
||||
if(String(isLogin()?.id||'')!==memberId)return
|
||||
if(res.data?.success){const o=res.data.result;purchaseRequest='';if(o.status==='PAID')uni.redirectTo({url:'/pages/mine/paidMember?payment_sn='+encodeURIComponent(o.sn)});else uni.navigateTo({url:`/pages/cart/payment/payOrder?paid_member_sn=${o.sn}`})}
|
||||
else{purchaseRequest='';agreed.value=false;await load()}
|
||||
}catch{uni.showToast({title:'购买结果暂未确认,请重试或查看购买记录',icon:'none'})}finally{buying.value=false}
|
||||
}
|
||||
function stopPolling(){pollId++;if(timer)clearTimeout(timer);timer=null}
|
||||
async function checkPayment(){
|
||||
if(!paymentSn)return
|
||||
const id=++pollId;pollMessage.value='正在确认支付结果…'
|
||||
try{const res=await getPaidMemberOrder(paymentSn);if(id!==pollId)return;if(res.data?.success && res.data.result.status==='PAID'){paymentSn='';pollMessage.value='支付已确认,会员资格已更新';await load();return}pollCount++;if(pollCount<10){timer=setTimeout(checkPayment,1500)}else pollMessage.value='支付结果待确认,点击刷新或查看购买记录'}catch{if(id===pollId)pollMessage.value='支付结果待确认,点击重试'}
|
||||
}
|
||||
onLoad(options=>{paymentSn=options?.payment_sn||''})
|
||||
onShow(async()=>{await load();if(paymentSn){pollCount=0;checkPayment()}})
|
||||
onPullDownRefresh(load)
|
||||
onHide(stopPolling)
|
||||
onUnload(()=>{request++;stopPolling()})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.paid-page{min-height:100vh;box-sizing:border-box;background:#f5f6f9;padding:28rpx 28rpx 240rpx;color:#292f39}.member-card{position:relative;overflow:hidden;border-radius:24rpx;min-height:240rpx}.card-bg{position:absolute;inset:0;width:100%;height:100%}.card-content{position:relative;padding:36rpx}.card-title{display:flex;align-items:center;gap:16rpx;font-size:38rpx;font-weight:600}.member-icon{width:70rpx;height:70rpx}.member-status,.saved{display:block;margin-top:18rpx;font-size:24rpx}.saved{opacity:.8}.purchase-card,.section{margin-top:24rpx;background:#fff;padding:28rpx;border-radius:20rpx}.price{font-size:48rpx;font-weight:600;color:#a67b3d}.period{font-size:25rpx;color:#929aa6}.hint{display:block;margin-top:14rpx;font-size:23rpx;line-height:1.7;color:#929aa6}.section-heading{display:flex;align-items:center;justify-content:space-between;font-size:30rpx;font-weight:600;margin-bottom:24rpx}.record-link{font-size:23rpx;font-weight:400;color:#a78149}.rights-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:28rpx 16rpx}.right-item{display:flex;flex-direction:column;align-items:center;gap:12rpx;text-align:center;font-size:24rpx}.right-item image{width:72rpx;height:72rpx}.discount-note{margin-top:24rpx;padding:20rpx;background:#fbf6ed;border-radius:14rpx;font-size:23rpx;color:#a78149;line-height:1.8}.discount-note text{display:block}.goods-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24rpx}.goods-cover{width:100%;height:230rpx}.goods-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:24rpx;line-height:1.6}.goods-price,.member-price{display:block;margin-top:8rpx;font-size:25rpx}.member-price{display:flex;align-items:center;gap:6rpx;color:#9c7139;font-size:22rpx}.member-price-label{width:28rpx;height:28rpx;flex-shrink:0}.purchase-footer{position:fixed;left:0;right:0;bottom:0;background:#fff;padding:22rpx 28rpx calc(22rpx + env(safe-area-inset-bottom));border-top:1rpx solid #eee;z-index:8}.agreement-row{display:flex;justify-content:center;align-items:center;gap:8rpx;font-size:22rpx;color:#929aa6;margin-bottom:18rpx}.agreement-link{color:#a78149}.buy-button,.close-button{background:#ead5b1;color:#705125;border-radius:44rpx;font-size:28rpx;line-height:84rpx}button::after{border:none}.empty{padding:160rpx 20rpx;text-align:center;color:#969da8;font-size:27rpx}.poll-message{padding:20rpx;background:#fff2d9;border-radius:12rpx;margin-top:20rpx;color:#9d753e;font-size:24rpx}.overlay{position:fixed;inset:0;z-index:20;display:flex;align-items:flex-end}.mask{position:absolute;inset:0;background:rgba(0,0,0,.4)}.sheet{position:relative;width:100%;background:#fff;border-radius:28rpx 28rpx 0 0;padding:30rpx;box-sizing:border-box;padding-bottom:calc(30rpx + env(safe-area-inset-bottom))}.sheet-title{display:block;text-align:center;font-size:32rpx;font-weight:600;margin-bottom:24rpx}.sheet-scroll{height:50vh;margin-bottom:24rpx;font-size:26rpx;line-height:1.8}
|
||||
</style>
|
||||
36
pages/mine/paidMemberRecords.vue
Normal file
36
pages/mine/paidMemberRecords.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<view class="records-page">
|
||||
<view v-for="order in rows" :key="order.id" class="record-card">
|
||||
<view class="heading"><text>{{ memberName(order) }}</text><text class="status">{{ status(order) }}</text></view>
|
||||
<text class="amount">¥{{ Number(order.amount).toFixed(2) }} <text class="months">/ {{ order.months }}个月</text></text>
|
||||
<text class="line">订单号:{{ order.sn }}</text><text class="line">创建时间:{{ order.createTime }}</text>
|
||||
<template v-if="order.status === 'PAID'"><text class="line">支付时间:{{ order.payTime }}</text><text class="line">有效期:{{ order.startTime }} 至 {{ order.endTime }}</text></template>
|
||||
<view v-if="canPay(order)" class="actions"><button class="cancel" @click="cancel(order)">取消订单</button><button class="pay" @click="pay(order)">继续支付</button></view>
|
||||
</view>
|
||||
<view v-if="!rows.length" class="empty">{{ loading ? '加载中…' : error ? '加载失败,点击重试' : '暂无购买记录' }}</view>
|
||||
<view v-if="error" class="load-more" @click="load(retryReset)">点击重新加载</view><view v-else-if="rows.length" class="load-more" @click="load(false)">{{ loading ? '加载中…' : more ? '上拉或点击加载更多' : '已显示全部记录' }}</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onReachBottom, onPullDownRefresh, onUnload } from '@dcloudio/uni-app'
|
||||
import { getPaidMemberOrders, cancelPaidMemberOrder } from '@/api/paid-member'
|
||||
import { isLogin, navigateToLogin } from '@/utils/filters'
|
||||
const rows=ref([]),loading=ref(false),error=ref(false),more=ref(true)
|
||||
let page=1,owner='',request=0,retryReset=true
|
||||
const time=value=>new Date(String(value||'').replace(/-/g,'/')).getTime()
|
||||
function memberName(order){try{return JSON.parse(order.configSnapshot).name}catch{return '付费会员'}}
|
||||
function canPay(order){return order.status==='UNPAID' && time(order.payDeadline)>Date.now()}
|
||||
function status(order){return order.status==='PAID' ? time(order.startTime)>Date.now() ? '待生效' : time(order.endTime)<=Date.now() ? '已到期' : '会员有效' : order.status==='CANCELLED' ? '已取消' : canPay(order) ? '待支付' : '已超时'}
|
||||
function pay(order){uni.navigateTo({url:`/pages/cart/payment/payOrder?paid_member_sn=${order.sn}`})}
|
||||
function cancel(order){uni.showModal({title:'取消会员订单',content:'确定取消这笔未支付订单?',success:async result=>{if(result.confirm){const res=await cancelPaidMemberOrder(order.sn);if(res.data?.success)load(true)}}})}
|
||||
async function load(reset=false){
|
||||
const memberId=String(isLogin()?.id||'');if(!memberId){request++;rows.value=[];owner='';navigateToLogin();uni.stopPullDownRefresh();return}
|
||||
if(!reset && (loading.value || !more.value))return
|
||||
if(owner!==memberId){owner=memberId;rows.value=[];reset=true}
|
||||
const id=++request,next=reset?1:page;loading.value=true;error.value=false;retryReset=reset
|
||||
try{const res=await getPaidMemberOrders({pageNumber:next,pageSize:20});if(id!==request||String(isLogin()?.id||'')!==memberId)return;if(!res.data?.success)throw new Error('load');const data=res.data.result||{};rows.value=reset ? data.records||[] : [...rows.value,...(data.records||[]).filter(row=>!rows.value.some(old=>old.id===row.id))];page=next+1;more.value=next*20<Number(data.total||0);retryReset=false}catch{if(id===request)error.value=true}finally{if(id===request)loading.value=false;uni.stopPullDownRefresh()}
|
||||
}
|
||||
onShow(()=>load(true));onPullDownRefresh(()=>load(true));onReachBottom(()=>{if(!error.value)load(false)});onUnload(()=>request++)
|
||||
</script>
|
||||
<style scoped>.records-page{min-height:100vh;box-sizing:border-box;padding:24rpx 28rpx calc(30rpx + env(safe-area-inset-bottom));background:#f5f6f9}.record-card{padding:28rpx;background:#fff;border-radius:20rpx;margin-bottom:20rpx}.heading{display:flex;align-items:center;justify-content:space-between;font-size:29rpx;font-weight:600}.status{font-size:23rpx;color:#a78149;font-weight:400}.amount{display:block;font-size:40rpx;margin:20rpx 0;color:#9d753e}.months{font-size:23rpx;color:#989faa}.line{display:block;font-size:22rpx;line-height:1.9;color:#929aa6;overflow-wrap:anywhere}.actions{display:flex;justify-content:flex-end;gap:16rpx;margin-top:22rpx}.actions button{margin:0;padding:0 26rpx;font-size:23rpx;line-height:58rpx;border-radius:30rpx}.actions button::after{border:none}.pay{background:#f0dfc2;color:#8d6836}.cancel{background:#f2f3f5;color:#929aa6}.empty,.load-more{padding:40rpx 0;text-align:center;font-size:25rpx;color:#a0a7b1}</style>
|
||||
Reference in New Issue
Block a user