6 Commits

Author SHA1 Message Date
pikachu1995@126.com
75b20f494d feat(gift-card): 新增提货卡兑换功能
- 新建提货卡选品及确认兑换页面,支持全选和任选模式
- 新增提货卡 API 接口,包含获取、试算和核销端点
- 我的礼品卡页面增加提货卡类型标识及面值展示适配
- 订单取消弹窗针对提货卡订单显示专属提示文案
- 配置 pages.json 新增提货卡兑换页面路由
2026-11-06 15:16:29 +08:00
pikachu1995@126.com
cc71d20aa6 feat(paid-member): 添加付费会员功能模块
- 新增付费会员API接口层,支持会员查询、下单、记录及报价
- 新增付费会员页面,展示权益、推荐商品及购买流程
- 新增付费会员购买记录页面,支持订单状态查看与继续支付
- 新增 PaidMemberPrice 组件用于商品列表和详情页展示会员价
- 个人中心页添加付费会员入口,支持配置化展示标题与背景
- 商品详情页集成会员价展示,支持秒杀和拼团场景下隐藏
- 商品列表组件集成会员价展示,支持批量请求与防竞态
- 支付页适配付费会员支付流程,支持H5轮询与结果回调
- 订单确认页优惠文案更新为"活动优惠(含会员优惠)"
- pages.json 新增付费会员及购买记录页面路由配置
- README 更新升级计划,按时间倒序补充已完成功能明细
2026-11-05 22:37:12 +08:00
pikachu1995@126.com
06af86af3f feat(member): 完善移动端等级权益与经验任务 2026-11-05 21:29:56 +08:00
pikachu1995@126.com
8188a8af7b refactor: 清理停用促销的移动端展示 2026-11-05 15:20:38 +08:00
pikachu1995@126.com
0637f1da1f docs: 标记立减优惠券升级计划为已完成 2026-11-04 14:50:33 +08:00
pikachu1995@126.com
4adcf0a13a feat(coupon): 支持优惠券无门槛使用条件展示
- 优惠券消费阈值为0时显示"无门槛使用"替代"满0元可用"
- 更新商品详情页促销优惠券组件的条件展示逻辑
- 更新购物车优惠券中心页面门槛显示
- 更新优惠券详情页门槛文案处理
- 更新首页领券模板,增加无门槛条件分支
- 更新我的优惠券页面新增门槛描述字段
- 更新店铺页优惠券领取区域门槛显示
- 更新首页优惠券模板组件门槛文案
2026-11-04 14:03:51 +08:00
33 changed files with 2785 additions and 1887 deletions

View File

@@ -160,18 +160,44 @@ lilishop-uniapp
## 8. 升级计划 ## 8. 升级计划
> 更新日期:2026-11-03,依据各端近期提交及当前代码实现同步。 > 更新日期:2026-11-05,依据各端提交记录及当前代码实现同步。
### ✅ 已完成特性 ### ✅ 已完成特性
| 功能模块 | 功能模块 |
| :--- | :--- | 按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) | | 时间 | 功能模块 | 完成内容 |
| ✅ 置顶评论 | ✅ 客户等级体系 | | :--- | :--- | :--- |
| ✅ 购物卡 | ✅ 腾讯云-云直播 | | 2026-11-05 | ✅ 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
| ✅ 卡密商品 | ✅ 二级分销 | | 2026-11-04 | ✅ 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
| ✅ 定时任务管理(数据库配置驱动) | ✅ 预约服务商品 | | 2026-11-04 | ✅ 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
| ✅ 主题色管理 | | | 2026-11-04 | ✅ 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
| 2026-11-04 | ✅ 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
| 2026-11-04 | ✅ 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
| 2026-11-02 | ✅ 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
| 2026-10-30 | ✅ PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
| 2026-10-29 | ✅ 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
| 2026-10-29 | ✅ 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
| 2026-10-29 | ✅ PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
| 2026-10-22 | ✅ 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
| 2026-10-20 | ✅ 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
| 2026-10-09 | ✅ 主题色管理 | 完善主题设置,支持自定义模式。 |
| 2026-10-02 | ✅ 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
| 2026-08-05 | ✅ 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
| 2026-08-03 | ✅ 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
| 2026-07-23 | ✅ 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
| 2026-07-20 | ✅ 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
| 2026-07-13 | ✅ 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
| 2026-06-30 | ✅ 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
| 2026-06-29 | ✅ 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
| 2026-06-05 | ✅ 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
| 2026-05-22 | ✅ 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
| 2026-05-20 | ✅ 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
| 2026-04-16 | ✅ 置顶评论 | 支持评论置顶及商品评价管理。 |
| 2026-04-13 | ✅ 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
| 2026-04-01 | ✅ 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
| 2026-03-30 | ✅ 商品分组 | 新增商品分组管理及商品关联功能。 |
| 2026-03-24 | ✅ 会员分组 | 新增会员分组管理。 |
### 🔜 近期规划中 ### 🔜 近期规划中
- 🔜 微信小店对接 - 🔜 微信小店对接

View File

@@ -477,3 +477,11 @@ export function withdrawalSettingVO() {
}); });
} }
export function getCurrentMemberBenefits() { return http.request({url:'/member/benefit/current',method:Method.GET,needToken:true}) }
export function getMemberGradeBenefits() { return http.request({url:'/member/memberGrade/list',method:Method.GET,needToken:true}) }
/** 仅返回后台已开启的经验值规则及当前会员累计获得情况。 */
export function getExperienceTasks() { return http.request({ url: '/member/memberGrade/rules', method: Method.GET, needToken: true }) }
/** 当前会员等级、经验值及分页流水;会员身份由服务端登录态确定。 */
export function getExperienceRecords(params) { return http.request({ url: '/member/memberGrade/current', method: Method.GET, params, needToken: true }) }

7
api/paid-member.js Normal file
View File

@@ -0,0 +1,7 @@
import { http, Method } from '@/utils/request.js'
export const getPaidMember = () => http.request({ url: '/member/paid/current', method: Method.GET, needToken: true })
export const createPaidMemberOrder = data => http.request({ url: '/member/paid/orders', method: Method.POST, data, needToken: true, header: { 'Content-Type': 'application/json' } })
export const getPaidMemberOrders = params => http.request({ url: '/member/paid/orders', method: Method.GET, params, needToken: true })
export const getPaidMemberOrder = sn => http.request({ url: `/member/paid/orders/${encodeURIComponent(sn)}`, method: Method.GET, needToken: true })
export const cancelPaidMemberOrder = sn => http.request({ url: `/member/paid/orders/${encodeURIComponent(sn)}/cancel`, method: Method.POST, needToken: true })
export const getPaidMemberQuotes = skuIds => http.request({ url: '/member/paid/quotes', method: Method.GET, params: { skuIds: skuIds.join(',') }, needToken: true })

13
api/pickupCard.js Normal file
View File

@@ -0,0 +1,13 @@
/**
* 提货卡选品、试算及一次兑换接口。
* @author Bulbasaur
* @since 2026-11-05
*/
import { http, Method } from '@/utils/request.js'
/** 获取当前会员的卡规则及候选商品。 */
export const getPickupCard = id => http.request({ url: `/promotion/pickupCard/${encodeURIComponent(id)}`, method: Method.GET, needToken: true })
/** 服务端试算,校验商品与地址且不消耗权益。 */
export const previewPickupCard = data => http.request({ url: '/promotion/pickupCard/preview', method: Method.POST, needToken: true, header: { 'content-type': 'application/json' }, data })
/** 原子核销创单,网络重试返回原交易。 */
export const redeemPickupCard = data => http.request({ url: '/promotion/pickupCard/redeem', method: Method.POST, needToken: true, header: { 'content-type': 'application/json' }, data })

View File

@@ -20,6 +20,7 @@
}} }}
</div> </div>
</view> </view>
<PaidMemberPrice :quote="paidQuotes[item.id]" />
<div class="promotion" @click="navigateToDetailPage(item)"> <div class="promotion" @click="navigateToDetailPage(item)">
<div v-for="(promotionItem,promotionIndex) in getPromotion(item)" :key="promotionIndex"> <div v-for="(promotionItem,promotionIndex) in getPromotion(item)" :key="promotionIndex">
<span class="promotion-tag" v-if="promotionItem.indexOf('COUPON') != -1">劵</span> <span class="promotion-tag" v-if="promotionItem.indexOf('COUPON') != -1">劵</span>
@@ -80,6 +81,7 @@
<u-icon name="shopping-cart" size="16" :color="mainColor"></u-icon> <u-icon name="shopping-cart" size="16" :color="mainColor"></u-icon>
</div> </div>
</view> </view>
<PaidMemberPrice v-if="flag('price')" :quote="paidQuotes[item.id]" />
<div class="store-seller-name" v-if="storeName" @click="navigateToStoreDetailPage(item)"> <div class="store-seller-name" v-if="storeName" @click="navigateToStoreDetailPage(item)">
<view class="store-name-row"> <view class="store-name-row">
<text class="self-tag" v-if="item.selfOperated && flag('selfTag')">自营</text> <text class="self-tag" v-if="item.selfOperated && flag('selfTag')">自营</text>
@@ -96,7 +98,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue' import { computed, watch, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import PaidMemberPrice from '@/components/paid-member-price/index.vue'
import { getPaidMemberQuotes } from '@/api/paid-member'
import { isLogin } from '@/utils/filters'
import { useStore } from '@/store' import { useStore } from '@/store'
import { useGoodsListCommon } from './common' import { useGoodsListCommon } from './common'
import { goodsFormatPrice } from '@/utils/filters.js' import { goodsFormatPrice } from '@/utils/filters.js'
@@ -123,6 +129,21 @@ const props = withDefaults(defineProps<{
}) })
const emit = defineEmits(['addCart']) const emit = defineEmits(['addCart'])
const paidQuotes = ref<Record<string, any>>({})
let paidRequest = 0
async function loadPaidQuotes() {
const request = ++paidRequest, memberId = String(isLogin()?.id || '')
paidQuotes.value = {}
if (!memberId) return
const ids = [...new Set((props.res || []).map(item => String(item.id || '')).filter(Boolean))]
try {
const results = await Promise.all(Array.from({length:Math.ceil(ids.length/100)},(_,i)=>getPaidMemberQuotes(ids.slice(i*100,i*100+100))))
if (request !== paidRequest || String(isLogin()?.id || '') !== memberId) return
paidQuotes.value = Object.assign({}, ...results.filter(res=>res.data?.success).map(res=>res.data.result || {}))
} catch {}
}
watch(() => [(props.res || []).map(item=>item.id).join(','), store.state.userInfo?.id, store.state.hasLogin], loadPaidQuotes, {immediate:true})
onShow(loadPaidQuotes)
// 存量字段(促销/销量/自营/价格):showFields 为 null 时维持现状渲染 // 存量字段(促销/销量/自营/价格):showFields 为 null 时维持现状渲染
const flag = (key: string) => props.showFields == null || props.showFields[key] !== false const flag = (key: string) => props.showFields == null || props.showFields[key] !== false

View File

@@ -0,0 +1,6 @@
<template><view v-if="quote" class="paid-price" @click.stop="open"><image v-if="quote.label" :src="quote.label" mode="aspectFit" /><text>会员价 ¥{{ Number(quote.price).toFixed(2) }}</text><text v-if="!quote.eligible" class="open">开通享 ›</text></view></template>
<script setup>
defineProps({quote:{type:Object,default:null}})
function open(){uni.navigateTo({url:'/pages/mine/paidMember'})}
</script>
<style scoped>.paid-price{display:flex;align-items:center;flex-wrap:wrap;gap:6rpx;margin-top:8rpx;color:#a77c3e;font-size:22rpx;line-height:1.6}.paid-price image{width:28rpx;height:28rpx}.open{font-size:20rpx;opacity:.8}</style>

View File

@@ -3,7 +3,7 @@
"name" : "lilishop", "name" : "lilishop",
"appid" : "__UNI__ED807EA", "appid" : "__UNI__ED807EA",
"description" : "", "description" : "",
"versionName" : "5.03", "versionName" : "5.0.4",
"versionCode" : 503, "versionCode" : 503,
"transformPx" : false, "transformPx" : false,
"app-plus" : { "app-plus" : {

View File

@@ -173,6 +173,10 @@
{ {
"root": "pages/mine", "root": "pages/mine",
"pages": [ "pages": [
{"path":"memberBenefits","style":{"navigationBarTitleText":"会员权益","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{"path":"paidMember","style":{"navigationBarTitleText":"付费会员","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{"path":"paidMemberRecords","style":{"navigationBarTitleText":"会员购买记录","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{"path":"experience","style":{"navigationBarTitleText":"我的经验","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{ {
"path": "signIn", "path": "signIn",
@@ -844,7 +848,11 @@
} }
}, },
{ {
"path": "gift-card/myGiftCards", "path": "gift-card/pickupCard",
"style": { "navigationBarTitleText": "提货卡兑换" }
},
{
"path": "gift-card/myGiftCards",
"style": { "style": {
"navigationBarTitleText": "我的礼品卡" "navigationBarTitleText": "我的礼品卡"
} }

View File

@@ -11,7 +11,7 @@
<view class="coupon-card-right"> <view class="coupon-card-right">
<view class="coupon-info"> <view class="coupon-info">
<text class="coupon-card-name">{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text> <text class="coupon-card-name">{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text>
<text class="coupon-card-desc">满{{unitPrice(item.consumeThreshold) }}元可用</text> <text class="coupon-card-desc">{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
<text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime.split(" ")[0] }}</text> <text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime.split(" ")[0] }}</text>
</view> </view>
<view class="coupon-claim-btn" @click="receive(item)"> <view class="coupon-claim-btn" @click="receive(item)">

View File

@@ -5,7 +5,7 @@
<view class="title">{{ coupon.title }}</view> <view class="title">{{ coupon.title }}</view>
<view class="price" v-if="coupon.couponType == 'PRICE'"><text>¥</text>{{ unitPrice(coupon.price) }}</view> <view class="price" v-if="coupon.couponType == 'PRICE'"><text>¥</text>{{ unitPrice(coupon.price) }}</view>
<view class="price" v-if="coupon.couponType == 'DISCOUNT'">{{ coupon.discount }}折</view> <view class="price" v-if="coupon.couponType == 'DISCOUNT'">{{ coupon.discount }}折</view>
<view class="text">满{{ coupon.consumeThreshold }}元可用</view> <view class="text">{{ coupon.consumeThreshold > 0 ? '满' + coupon.consumeThreshold + '元可用' : '无门槛使用' }}</view>
<view class="bg-quan">券</view> <view class="bg-quan">券</view>
<view class="jiao-1" :class="{ 'used-color': coupon.used_status != 0 }"> <view class="jiao-1" :class="{ 'used-color': coupon.used_status != 0 }">
<text class="text-1">{{ coupon.used_status == 0 ? '新到' : coupon.used_status_text }}</text> <text class="text-1">{{ coupon.used_status == 0 ? '新到' : coupon.used_status_text }}</text>

View File

@@ -41,7 +41,7 @@
</template> </template>
使用 使用
</text> </text>
<text class="coupon-card-desc">满{{ unitPrice(item.consumeThreshold) }}元可用</text> <text class="coupon-card-desc">{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
<text class="coupon-card-time" v-if="item.reason">{{ item.reason }}</text> <text class="coupon-card-time" v-if="item.reason">{{ item.reason }}</text>
<text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime }}</text> <text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime }}</text>
</view> </view>

View File

@@ -47,6 +47,7 @@
<view class="coupon-card-right"> <view class="coupon-card-right">
<view class="coupon-info"> <view class="coupon-info">
<text class="coupon-card-name">{{ coupon.title }}</text> <text class="coupon-card-name">{{ coupon.title }}</text>
<text class="coupon-card-desc">{{ coupon.consumeThreshold > 0 ? '满' + unitPrice(coupon.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
<text class="coupon-card-desc">使用范围:{{ <text class="coupon-card-desc">使用范围:{{
coupon.scopeType == "ALL" && coupon.storeId == "0" coupon.scopeType == "ALL" && coupon.storeId == "0"
? "全平台" ? "全平台"

View File

@@ -52,17 +52,18 @@
<view class="gcc-header-inner"> <view class="gcc-header-inner">
<view class="gcc-header-left"> <view class="gcc-header-left">
<view class="gcc-title">{{ item.giftCardName || '礼品卡' }}</view> <view class="gcc-title">{{ item.giftCardName || '礼品卡' }}</view>
<view class="gcc-face">面值{{ item.faceText }}元</view> <view class="gcc-face">{{ item.cardType === 'PICKUP' ? '兑换 ' + item.pickupCount + ' 种商品' : '面值' + item.faceText + '元' }}</view>
<view class="gcc-cardno">{{ item.cardNo }}</view> <view class="gcc-cardno">{{ item.cardNo }}</view>
</view> </view>
<view class="gcc-header-right"> <view class="gcc-header-right">
<view class="gcc-type">现金卡</view> <view class="gcc-type">{{ item.cardType === 'PICKUP' ? '提货卡' : '现金卡' }}</view>
<view class="gcc-expire">{{ item.expireText }}</view> <view class="gcc-expire">{{ item.expireText }}</view>
</view> </view>
</view> </view>
</view> </view>
<view class="gcc-body"> <view class="gcc-body">
<view class="gcc-row"> <view v-if="item.cardType === 'PICKUP'" class="gcc-row">每种一件 · 一次兑完 · 免邮</view>
<view v-else class="gcc-row">
当前余额: 当前余额:
<text class="gcc-strong" v-if="item.balance != null">¥{{ unitPrice(item.balance) }}</text> <text class="gcc-strong" v-if="item.balance != null">¥{{ unitPrice(item.balance) }}</text>
<text class="gcc-strong" v-else>—</text> <text class="gcc-strong" v-else>—</text>
@@ -236,7 +237,8 @@ function onCardPrimaryAction(item: any) {
return return
} }
if (statusTab.value === GIFT_CARD_STATUS.AVAILABLE) { if (statusTab.value === GIFT_CARD_STATUS.AVAILABLE) {
uni.switchTab({ url: '/pages/tabbar/home/index' }) if (item.cardType === 'PICKUP') uni.navigateTo({ url: '/pages/cart/gift-card/pickupCard?id=' + encodeURIComponent(resolveMemberCardId(item)) })
else uni.switchTab({ url: '/pages/tabbar/home/index' })
} }
} }
</script> </script>

View File

@@ -0,0 +1,118 @@
<!--
提货卡选品及确认兑换,兼容 H5、App 和微信小程序。
@author Bulbasaur
@since 2026-11-05
-->
<template>
<view class="pickup-page">
<view class="notice">每张卡一次兑完,每种一件,免邮。兑换成功后取消不退权益,不支持退款或退货。</view>
<view v-if="loading" class="state">加载中...</view>
<view v-else-if="error" class="state" @click="load">{{ error }},点击重试</view>
<template v-else-if="detail.credentialId">
<view class="title">{{ detail.giftCardName }}</view>
<view v-if="completed || detail.tradeSn" class="state">该提货卡已兑换<button @click="viewOrders">查看订单</button></view>
<template v-else>
<view class="rule">{{ detail.pickupMode === 'ALL' ? '兑换以下全部商品' : '任选 ' + detail.pickupCount + ' 种商品' }} · 已选 {{ selected.length }} 种</view>
<view v-for="goods in detail.goods" :key="goods.id" class="goods" :class="{ selected: selected.includes(goods.id), disabled: !goods.available }" @click="toggle(goods)">
<image :src="goods.thumbnail" mode="aspectFill" />
<view class="goods-info"><view>{{ goods.goodsName }}</view><view class="muted">{{ specsText(goods.specs) }}</view><view class="muted">{{ goods.storeName }} · 1 件</view><text v-if="!goods.available">暂不可兑换</text></view>
<text>{{ selected.includes(goods.id) ? '●' : '○' }}</text>
</view>
<view class="address">
<view class="title">收货地址</view>
<picker :range="addresses" :range-key="'label'" :value="addressIndex" :disabled="busy" @change="chooseAddress"><view>{{ addresses[addressIndex]?.label || '请选择收货地址' }} ›</view></picker>
<view class="links"><text @click="manageAddress">管理收货地址</text><text @click="loadAddresses">刷新地址</text></view>
</view>
<button class="submit" :loading="busy" :disabled="!canSubmit || busy" @click="submit">确认兑换 · 实付 ¥0</button>
</template>
</template>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { getAddressList } from '@/api/address.js'
import { getPickupCard, previewPickupCard, redeemPickupCard } from '@/api/pickupCard.js'
import storage from '@/utils/storage.js'
const detail = ref({}), selected = ref([]), addresses = ref([]), addressIndex = ref(0)
const loading = ref(false), busy = ref(false), error = ref(''), completed = ref(false)
let cardId = '', active = true, version = 0, owner = ''
const canSubmit = computed(() => selected.value.length === detail.value.pickupCount && !!addresses.value[addressIndex.value] && !detail.value.goods?.some(g => selected.value.includes(g.id) && !g.available))
/** 隔离账号切换和离页后的异步响应。 */
function sameOwner() { return active && owner === String(storage.getUserInfo()?.id || '') }
/** 将既有 SKU 规格转成可读文字。 */
function specsText(value) { try { const specs = JSON.parse(value || '[]'); return Array.isArray(specs) ? specs.filter(s => s.specName !== 'images').map(s => s.specValue).join(' / ') : '' } catch { return value || '' } }
/** 非全选模式可增减选项,数量仍由后端再次校验。 */
function toggle(goods) {
if (busy.value || !goods.available || detail.value.pickupMode === 'ALL') return
if (selected.value.includes(goods.id)) selected.value = selected.value.filter(id => id !== goods.id)
else if (detail.value.pickupCount === 1) selected.value = [goods.id]
else if (selected.value.length < detail.value.pickupCount) selected.value = [...selected.value, goods.id]
else uni.showToast({ title: '已达到可兑换种数', icon: 'none' })
}
/** 读取当前会员地址并保留有效选项。 */
async function loadAddresses() {
const previous = addresses.value[addressIndex.value]?.id
const response = await getAddressList(1, 100)
if (!sameOwner()) return
if (!response.data.success) throw new Error(response.data.message || '地址加载失败')
addresses.value = (response.data.result?.records || []).map(a => ({ ...a, label: `${a.name} ${a.mobile} ${a.consigneeAddressPath || ''} ${a.detail || ''}` }))
const index = addresses.value.findIndex(a => a.id === previous)
addressIndex.value = index >= 0 ? index : Math.max(0, addresses.value.findIndex(a => a.isDefault))
}
/** 刷新卡可用性;返回页面重新校验已选商品。 */
async function load() {
const request = ++version
loading.value = true; error.value = ''
try {
const response = await getPickupCard(cardId)
if (!sameOwner() || request !== version) return
if (!response.data.success) throw new Error(response.data.message || '卡片加载失败')
detail.value = response.data.result
selected.value = detail.value.pickupMode === 'ALL' ? detail.value.goods.map(g => g.id) : selected.value.filter(id => detail.value.goods.some(g => g.id === id && g.available))
await loadAddresses()
} catch (e) { if (sameOwner() && request === version) error.value = e.message || '加载失败' }
finally { if (sameOwner() && request === version) loading.value = false }
}
/** 提交前服务端试算;确认后核销,网络错误允许幂等重试。 */
async function submit() {
if (busy.value || !canSubmit.value || !sameOwner()) return
busy.value = true
const payload = { credentialId: cardId, skuIds: [...selected.value], addressId: addresses.value[addressIndex.value].id }
try {
const preview = await previewPickupCard(payload)
if (!sameOwner()) return
if (!preview.data.success) throw new Error(preview.data.message || '兑换校验失败')
const confirmed = await new Promise(resolve => uni.showModal({ title: '确认兑换', content: '每种一件,免邮且实付为零。兑换后取消不退权益,不支持退款或退货。', confirmText: '确认兑换', success: result => resolve(result.confirm), fail: () => resolve(false) }))
if (!confirmed || !sameOwner()) return
const response = await redeemPickupCard(payload)
if (!sameOwner()) return
if (!response.data.success) throw new Error(response.data.message || '兑换失败')
completed.value = true
} catch (e) {
if (sameOwner()) {
try { const status = await getPickupCard(cardId); if (sameOwner() && status.data.success && status.data.result?.tradeSn) { detail.value = status.data.result; completed.value = true; return } } catch { /* 保留选项以便重试 */ }
uni.showToast({ title: e.message || '结果待确认,请重试或查看订单', icon: 'none' })
}
}
finally { if (sameOwner()) busy.value = false }
}
/** 地址选择仅改变本页状态,不修改地址本身。 */
function chooseAddress(event) { addressIndex.value = Number(event.detail.value) }
/** 复用现有地址管理,返回时刷新。 */
function manageAddress() { if (!busy.value) uni.navigateTo({ url: '/pages/mine/address/addressManage' }) }
/** 兑换订单沿用普通订单列表与物流详情。 */
function viewOrders() { uni.navigateTo({ url: '/pages/order/myOrder?status=0' }) }
onLoad(options => { cardId = options.id || ''; owner = String(storage.getUserInfo()?.id || '') })
onShow(() => {
if (owner !== String(storage.getUserInfo()?.id || '')) { detail.value = {}; selected.value = []; addresses.value = []; error.value = '账号已变更,请返回我的礼品卡'; return }
load()
})
onUnload(() => { active = false; version++ })
</script>
<style scoped>
.pickup-page{min-height:100vh;background:#f6f7f9;padding:24rpx;box-sizing:border-box;padding-bottom:calc(40rpx + env(safe-area-inset-bottom))}.notice{background:#fff5e8;color:#965b24;padding:24rpx;border-radius:16rpx;font-size:26rpx;line-height:1.7}.title{font-weight:600;font-size:32rpx;margin:28rpx 0 20rpx}.rule,.muted{color:#777;font-size:24rpx;margin:12rpx 0}.goods{display:flex;align-items:center;gap:20rpx;background:#fff;border:2rpx solid transparent;border-radius:16rpx;padding:24rpx;margin:16rpx 0}.goods.selected{border-color:#f56c36}.goods.disabled{opacity:.5}.goods image{width:130rpx;height:130rpx;border-radius:12rpx;flex-shrink:0}.goods-info{flex:1;font-size:28rpx}.address{background:#fff;padding:4rpx 24rpx 24rpx;border-radius:16rpx;font-size:26rpx}.links{display:flex;justify-content:space-between;color:#e75d31;margin-top:24rpx}.submit{background:#e75d31;color:#fff;margin-top:32rpx;border-radius:48rpx;font-size:30rpx}.state{text-align:center;padding:60rpx 20rpx}
</style>

View File

@@ -68,7 +68,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, getCurrentInstance, onMounted, computed } from 'vue' import { ref, getCurrentInstance, onMounted, computed } from 'vue'
import { onLoad, onBackPress } from '@dcloudio/uni-app' import { onLoad, onBackPress, onUnload } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import * as API_Trade from '@/api/trade' import * as API_Trade from '@/api/trade'
import { payCallback } from '@/api/members' import { payCallback } from '@/api/members'
@@ -88,6 +88,8 @@ const paymentType = ref('')
const paymentClient = ref('') const paymentClient = ref('')
const walletValue = ref(0) const walletValue = ref(0)
const autoCancelTime = ref(0) const autoCancelTime = ref(0)
let h5PollTimer: ReturnType<typeof setInterval> | undefined
onUnload(() => { if (h5PollTimer) clearInterval(h5PollTimer) })
onLoad((val) => { onLoad((val) => {
routerVal.value = val || {} routerVal.value = val || {}
@@ -107,7 +109,9 @@ onLoad((val) => {
onBackPress((e) => { onBackPress((e) => {
if (e.from == 'backbutton') { if (e.from == 'backbutton') {
if (routerVal.value.recharge_sn) { if (routerVal.value.paid_member_sn) {
uni.redirectTo({ url: '/pages/mine/paidMemberRecords' })
} else if (routerVal.value.recharge_sn) {
uni.switchTab({ url: '/pages/tabbar/user/my' }) uni.switchTab({ url: '/pages/tabbar/user/my' })
} else { } else {
uni.navigateTo({ url: '/pages/order/myOrder?status=0' }) uni.navigateTo({ url: '/pages/order/myOrder?status=0' })
@@ -126,6 +130,10 @@ function hideLoadingIfNeeded() {
} }
function callback(paymentMethod: string) { function callback(paymentMethod: string) {
if (orderType.value === 'PAID_MEMBER') {
uni.redirectTo({ url: '/pages/mine/paidMember?payment_sn=' + encodeURIComponent(sn.value) })
return
}
const goSuccess = () => { const goSuccess = () => {
uni.redirectTo({ uni.redirectTo({
url: url:
@@ -145,7 +153,10 @@ function callback(paymentMethod: string) {
function cashierData() { function cashierData() {
const parms: Record<string, string> = {} const parms: Record<string, string> = {}
if (routerVal.value.recharge_sn) { if (routerVal.value.paid_member_sn) {
sn.value = routerVal.value.paid_member_sn
orderType.value = 'PAID_MEMBER'
} else if (routerVal.value.recharge_sn) {
sn.value = routerVal.value.recharge_sn sn.value = routerVal.value.recharge_sn
orderType.value = 'RECHARGE' orderType.value = 'RECHARGE'
} else if (routerVal.value.trade_sn) { } else if (routerVal.value.trade_sn) {
@@ -212,6 +223,11 @@ function onPayTimeout() {
} }
function goPayError(message = '支付失败,如果您已支付,请勿反复支付') { function goPayError(message = '支付失败,如果您已支付,请勿反复支付') {
if (orderType.value === 'PAID_MEMBER') {
uni.showToast({ title: '支付未完成,请在购买记录中查看结果', icon: 'none' })
uni.redirectTo({ url: '/pages/mine/paidMemberRecords' })
return
}
const query = [`orderSn=${encodeURIComponent(sn.value)}`, `message=${encodeURIComponent(message)}`] const query = [`orderSn=${encodeURIComponent(sn.value)}`, `message=${encodeURIComponent(message)}`]
uni.navigateTo({ url: `/pages/cart/payment/error?${query.join('&')}` }) uni.navigateTo({ url: `/pages/cart/payment/error?${query.join('&')}` })
} }
@@ -276,15 +292,24 @@ async function pay(payment: string) {
}) })
hideLoadingIfNeeded() hideLoadingIfNeeded()
} else { } else {
window.location.href = JSON.parse(response.result).h5_url const paymentUrl = typeof response.result === 'string' && response.result.startsWith('http')
? response.result : JSON.parse(response.result).h5_url
const returnUrl = orderType.value === 'PAID_MEMBER'
? window.location.href.split('#')[0] + '#/pages/mine/paidMember?payment_sn=' + encodeURIComponent(sn.value)
: window.location.href
window.location.href = paymentUrl + (paymentUrl.includes('?') ? '&' : '?') + 'redirect_url=' + encodeURIComponent(returnUrl)
const searchParams = { ...params, price: cashierParams.value } const searchParams = { ...params, price: cashierParams.value }
const timer = setInterval(() => { let attempts = 0
if (h5PollTimer) clearInterval(h5PollTimer)
h5PollTimer = setInterval(() => {
if (++attempts > 100) { clearInterval(h5PollTimer); return }
payCallback(searchParams).then((cbRes) => { payCallback(searchParams).then((cbRes) => {
if (cbRes.data.result) { if (cbRes.data.result) {
clearInterval(timer) clearInterval(h5PollTimer)
uni.navigateTo({ url: '/pages/order/myOrder' }) if (orderType.value === 'PAID_MEMBER') callback(paymentMethod)
else uni.navigateTo({ url: '/pages/order/myOrder' })
} }
}) }).catch(() => {})
}, 3000) }, 3000)
hideLoadingIfNeeded() hideLoadingIfNeeded()
} }

View File

@@ -0,0 +1,37 @@
<template>
<view class="member-card" :style="{ color: cardColor }">
<image v-if="grade.gradeBackground && !backgroundFailed" :key="grade.gradeBackground" class="card-background" :src="grade.gradeBackground"
mode="aspectFill" @load="backgroundLoaded = true" @error="backgroundFailed = true" />
<view class="member-card-content">
<view class="card-status">{{ isCurrent ? '当前等级' : '等级预览' }}</view>
<view class="grade-heading">
<image v-if="grade.gradeImage && !iconFailed" :key="grade.gradeImage" class="grade-icon" :src="grade.gradeImage" mode="aspectFit" @error="iconFailed = true" />
<u-icon v-else name="level" size="30" :color="cardColor" /><text class="grade-title">{{ grade.gradeName }}</text>
</view>
<text class="card-subtitle">{{ benefitCount }} 项专属权益 · {{ thresholdText }}</text>
</view>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({ grade: { type: Object, required: true }, benefitCount: { type: Number, default: 0 }, isCurrent: { type: Boolean, default: false } })
const backgroundLoaded = ref(false), backgroundFailed = ref(false), iconFailed = ref(false)
const cardColor = computed(() => {
if (!backgroundLoaded.value || backgroundFailed.value) return '#fff5df'
const color = props.grade.gradeFontColor
return /^#[0-9a-f]{6}$/i.test(color || '') ? color : '#333333'
})
const thresholdText = computed(() => props.grade.isDefault ? '默认等级' : `${Math.max(0, Number(props.grade.requiredExperience) || 0).toLocaleString('en-US')} 经验值可达`)
watch(() => props.grade.gradeBackground, () => { backgroundLoaded.value = false; backgroundFailed.value = false }, { flush: 'sync' })
watch(() => props.grade.gradeImage, () => { iconFailed.value = false }, { flush: 'sync' })
</script>
<style scoped lang="scss">
.member-card { position: relative; overflow: hidden; width: 100%; height: 100%; border-radius: 24rpx; background: linear-gradient(115deg, #51443b, #8e7351); }
.card-background { position: absolute; width: 100%; height: 100%; top: 0; left: 0; }
.member-card-content { position: relative; height: 100%; padding: 30rpx 32rpx; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
.card-status { border: 1rpx solid currentColor; opacity: .8; font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 20rpx; }
.grade-heading { display: flex; align-items: center; gap: 14rpx; margin: 18rpx 0 12rpx; max-width: 100%; }
.grade-icon { width: 68rpx; height: 68rpx; flex-shrink: 0; }
.grade-title { font-size: 38rpx; line-height: 1.4; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-subtitle { font-size: 23rpx; opacity: .85; }
</style>

164
pages/mine/experience.vue Normal file
View File

@@ -0,0 +1,164 @@
<template>
<view class="experience-page">
<view class="summary-card">
<text class="summary-label">当前经验值</text><text class="summary-number">{{ summaryLoaded ? formatNumber(experience) : '—' }}</text>
<text class="summary-grade">{{ gradeName || '经验积累,解锁更多等级权益' }}</text>
<text v-if="logError && !summaryLoaded" class="summary-retry" @click="loadLogs(true)">经验值加载失败,点击重试</text>
</view>
<view class="tabs"><view class="tab" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">经验值任务</view><view class="tab" :class="{ active: activeTab === 'logs' }" @click="activeTab = 'logs'">经验值明细</view></view>
<view v-if="activeTab === 'tasks'">
<view class="section-heading"><text>做任务,积累经验</text></view>
<view v-if="taskLoading && !tasksLoaded" class="state">正在加载任务…</view>
<view v-else-if="taskError" class="state"><text>任务加载失败</text><button class="retry" @click="loadTasks">点击重试</button></view>
<template v-else>
<view v-for="item in tasks" :key="item.ruleKey" class="task-card">
<view class="task-main"><view class="task-title-row"><text class="task-title">{{ taskName(item) }}</text><text class="reward">{{ rewardText(item) }}</text></view>
<text class="task-description">{{ taskDescription(item) }}</text>
<text class="task-progress">累计获得 {{ formatNumber(item.gainedExperience) }} 经验<text v-if="hasLimit(item)"> · 上限 {{ formatNumber(item.maxValue) }}</text></text>
</view>
<view class="task-footer"><text class="task-status">{{ statusText(item) }}</text><button class="task-button" :class="{ finished: isFinished(item) }" :disabled="isFinished(item)" @click="doTask(item)">{{ actionText(item) }}</button></view>
</view>
<view v-if="tasksLoaded && !tasks.length" class="state"><u-icon name="list" size="36" color="#bec4ce" /><text>暂无已开启的经验值任务</text></view>
<view v-if="description" class="rules-note"><text class="note-title">经验值说明</text><text>{{ description }}</text></view>
<text class="page-note">完成相应行为并满足规则后,由系统自动发放经验值,无需手动领取。</text>
</template>
</view>
<view v-else>
<view class="section-heading"><text>经验值变动记录</text><text class="section-caption">按时间倒序展示</text></view>
<view v-if="logsLoading && !records.length" class="state">正在加载明细…</view>
<view v-else-if="!records.length && logError" class="state"><text>明细加载失败</text><button class="retry" @click="retryLogs">点击重试</button></view>
<view v-else-if="!records.length" class="state"><u-icon name="list" size="36" color="#bec4ce" /><text>暂无经验值明细</text><text class="state-hint">完成已开启的任务后,可在这里查看</text></view>
<view v-else class="record-list">
<view v-for="item in records" :key="item.id" class="record-row"><view class="record-content"><text class="record-title">{{ item.content || ruleNames[item.ruleKey] || '经验值变动' }}</text><text class="record-time">{{ item.createTime || '—' }}</text><text class="record-balance">变动后经验:{{ formatNumber(item.experience) }}</text></view><text class="record-change" :class="{ negative: Number(item.variableExperience) < 0 }">{{ changeText(item.variableExperience) }}</text></view>
</view>
<view v-if="records.length" class="load-more" @click="retryLogs">{{ logsLoading ? '加载中…' : logError ? '加载失败,点击重试' : hasMore ? '点击或上拉加载更多' : '已显示全部明细' }}</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onShow, onReachBottom, onPullDownRefresh, onUnload } from '@dcloudio/uni-app'
import { getExperienceTasks, getExperienceRecords } from '@/api/members'
import { isLogin, navigateToLogin } from '@/utils/filters'
const activeTab = ref('tasks'), experience = ref(0), gradeName = ref(''), summaryLoaded = ref(false)
const tasks = ref([]), description = ref(''), taskLoading = ref(false), tasksLoaded = ref(false), taskError = ref(false)
const records = ref([]), logsLoading = ref(false), logError = ref(false), hasMore = ref(true)
let ownerId = '', taskRequest = 0, logRequest = 0, nextPage = 1, retryReset = true
const pageSize = 20
const ruleNames = { CONSUME: '购物消费', REGISTER: '注册会员', SIGN_IN: '每日签到', COMMENT: '商品评价', SHARE: '分享商城', PROFILE: '完善个人信息', FOLLOW_STORE: '关注店铺', BIND_WECHAT: '绑定微信', ADD_ADDRESS: '添加收货地址', SHARE_REGISTER: '邀请注册', SHARE_BUY: '邀请购买' }
const onceRules = new Set(['REGISTER', 'PROFILE', 'BIND_WECHAT', 'ADD_ADDRESS'])
const taskDescriptions = { CONSUME: '订单完成后,按实际消费金额向下取整计算经验。', REGISTER: '注册成功后获得一次经验奖励。', SIGN_IN: '每天签到可获得一次经验奖励。', COMMENT: '对已购买商品提交超过30字的评价,满足条件后获得经验。', SHARE: '通过商城的分享入口分享页面,满足条件后获得经验。', PROFILE: '完善个人信息,满足条件后获得一次经验奖励。', FOLLOW_STORE: '关注店铺可获得经验,同一店铺不重复发放。', BIND_WECHAT: '成功绑定微信,满足条件后获得一次经验奖励。', ADD_ADDRESS: '添加收货地址后获得一次经验奖励。', SHARE_REGISTER: '通过分享邀请新用户注册成功后获得经验。', SHARE_BUY: '通过分享邀请用户购买并满足奖励条件后获得经验。' }
function formatNumber(value) { return Math.max(0, Number(value) || 0).toLocaleString('en-US') }
function changeText(value) { const n = Number(value) || 0; return `${n > 0 ? '+' : n < 0 ? '-' : ''}${Math.abs(n).toLocaleString('en-US')}` }
function taskName(item) { return item.ruleName || ruleNames[item.ruleKey] || '经验值任务' }
function taskDescription(item) { return taskDescriptions[item.ruleKey] || '按商城配置的规则完成相应行为后获得经验。' }
function rewardText(item) { return item.ruleKey === 'CONSUME' ? `每1元 +${formatNumber(item.value)}` : `+${formatNumber(item.value)} 经验` }
function hasLimit(item) { return item.ruleKey !== 'CONSUME' && item.maxValue != null && Number(item.maxValue) > 0 }
function reachedLimit(item) { return hasLimit(item) && Number(item.gainedExperience) >= Number(item.maxValue) }
function isFinished(item) { return reachedLimit(item) || (onceRules.has(item.ruleKey) && Boolean(item.completed)) }
function statusText(item) { return reachedLimit(item) ? '已达经验上限' : isFinished(item) ? '已完成' : item.ruleKey === 'SIGN_IN' ? '每日可参与' : onceRules.has(item.ruleKey) ? '仅限一次' : '满足条件可获得' }
function actionText(item) { return isFinished(item) ? '已完成' : item.ruleKey === 'SIGN_IN' ? '去签到' : item.ruleKey === 'PROFILE' ? '去完善' : item.ruleKey === 'ADD_ADDRESS' ? '去添加' : item.ruleKey === 'COMMENT' ? '去评价' : item.ruleKey === 'CONSUME' || item.ruleKey === 'FOLLOW_STORE' || item.ruleKey.startsWith('SHARE') ? '去逛逛' : '查看规则' }
function doTask(item) {
if (isFinished(item)) return
const routes = { SIGN_IN: '/pages/mine/signIn', PROFILE: '/pages/mine/set/personMsg', ADD_ADDRESS: '/pages/mine/address/addressManage', COMMENT: '/pages/order/myOrder?status=4' }
if (routes[item.ruleKey]) uni.navigateTo({ url: routes[item.ruleKey] })
else if (item.ruleKey === 'CONSUME' || item.ruleKey === 'FOLLOW_STORE' || item.ruleKey.startsWith('SHARE')) uni.switchTab({ url: '/pages/tabbar/home/index' })
else uni.showModal({ title: taskName(item), content: taskDescription(item), showCancel: false })
}
function stillOwner(memberId) { return ownerId === memberId && String(isLogin()?.id || '') === memberId }
function reset() {
taskRequest++; logRequest++; ownerId = ''
tasks.value = []; records.value = []; description.value = ''; experience.value = 0; gradeName.value = ''
summaryLoaded.value = false; tasksLoaded.value = false; taskLoading.value = false; logsLoading.value = false
taskError.value = false; logError.value = false; hasMore.value = true; nextPage = 1; retryReset = true
}
async function loadTasks() {
if (!ownerId) return
const memberId = ownerId, id = ++taskRequest
taskLoading.value = true; taskError.value = false
try {
const response = await getExperienceTasks()
if (id !== taskRequest || !stillOwner(memberId)) return
if (!response.data?.success) throw new Error('tasks')
const data = response.data.result || {}
// 服务端 /rules 仅返回开启的任务;兼容接口未来增加 enabled 字段。
tasks.value = (data.items || []).filter(item => item && item.ruleKey && item.enabled !== false)
description.value = data.description || ''; tasksLoaded.value = true
} catch { if (id === taskRequest && stillOwner(memberId)) taskError.value = true }
finally { if (id === taskRequest) taskLoading.value = false }
}
async function loadLogs(refresh = false) {
if (!ownerId || (!refresh && (logsLoading.value || !hasMore.value))) return
const memberId = ownerId, id = ++logRequest, page = refresh ? 1 : nextPage
retryReset = refresh; logsLoading.value = true; logError.value = false
try {
const response = await getExperienceRecords({ pageNumber: page, pageSize })
if (id !== logRequest || !stillOwner(memberId)) return
if (!response.data?.success) throw new Error('logs')
const data = response.data.result || {}, pageData = data.experienceLogs || {}, rows = pageData.records || []
experience.value = data.experience || 0; gradeName.value = data.grade?.gradeName || ''; summaryLoaded.value = true
const previous = refresh ? [] : records.value
const ids = new Set(previous.map(item => String(item.id)))
records.value = previous.concat(rows.filter(item => !ids.has(String(item.id))))
hasMore.value = pageData.total != null ? page * pageSize < Number(pageData.total) : rows.length === pageSize
nextPage = page + 1; retryReset = false
} catch { if (id === logRequest && stillOwner(memberId)) logError.value = true }
finally { if (id === logRequest) logsLoading.value = false }
}
function retryLogs() { if (!logsLoading.value) loadLogs(retryReset) }
async function refresh() {
const memberId = String(isLogin()?.id || '')
if (!memberId) { reset(); uni.stopPullDownRefresh(); navigateToLogin(); return }
if (ownerId !== memberId) { reset(); ownerId = memberId }
await Promise.all([loadTasks(), loadLogs(true)])
uni.stopPullDownRefresh()
}
onLoad(options => { activeTab.value = options?.tab === 'logs' ? 'logs' : 'tasks' })
onShow(refresh)
onPullDownRefresh(refresh)
onReachBottom(() => { if (activeTab.value === 'logs' && !logError.value) loadLogs(false) })
onUnload(() => { taskRequest++; logRequest++ })
</script>
<style scoped lang="scss">
.experience-page { min-height: 100vh; box-sizing: border-box; padding: 24rpx 28rpx calc(36rpx + env(safe-area-inset-bottom)); background: #f5f6f9; color: #292f39; }
.summary-card { background: linear-gradient(115deg, #51443b, #8e7351); border-radius: 24rpx; padding: 32rpx; color: #fff1d8; display: flex; flex-direction: column; align-items: flex-start; }
.summary-label { font-size: 24rpx; opacity: .8; }
.summary-number { font-size: 58rpx; font-weight: 600; line-height: 1.5; }
.summary-grade { margin-top: 4rpx; font-size: 23rpx; opacity: .8; }
.summary-retry { margin-top: 16rpx; font-size: 23rpx; text-decoration: underline; }
.tabs { display: flex; margin: 24rpx 0 32rpx; padding: 8rpx; background: #eceef2; border-radius: 18rpx; }
.tab { flex: 1; padding: 18rpx 0; text-align: center; font-size: 27rpx; color: #858c97; border-radius: 14rpx; }
.tab.active { color: #896337; background: #fff; font-weight: 600; }
.section-heading { display: flex; justify-content: space-between; align-items: center; font-size: 28rpx; font-weight: 600; margin-bottom: 20rpx; }
.section-caption { color: #9ba2ad; font-size: 21rpx; font-weight: 400; }
.task-card { padding: 26rpx; margin-bottom: 20rpx; background: #fff; border-radius: 20rpx; }
.task-title-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 10rpx; }
.task-title { font-size: 29rpx; font-weight: 600; }
.reward { font-size: 25rpx; color: #aa7a3c; }
.task-description { display: block; font-size: 24rpx; color: #89919d; margin-top: 14rpx; line-height: 1.7; }
.task-progress { display: block; font-size: 22rpx; color: #a1a8b2; margin-top: 12rpx; }
.task-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 20rpx; padding-top: 18rpx; border-top: 1rpx solid #f2f3f6; }
.task-status { color: #929aa5; font-size: 22rpx; }
.task-button { padding: 0 28rpx; margin: 0; line-height: 58rpx; font-size: 24rpx; border-radius: 32rpx; color: #886135; background: #f5ead8; }
.task-button.finished { background: #f2f3f5; color: #a2a8b0; }
button::after { border: none; }
.state { padding: 80rpx 20rpx; display: flex; flex-direction: column; align-items: center; gap: 20rpx; text-align: center; font-size: 26rpx; color: #949ca8; }
.state-hint { font-size: 23rpx; }
.retry { margin: 0; padding: 0 30rpx; line-height: 64rpx; background: #f4e8d5; color: #927043; font-size: 25rpx; border-radius: 32rpx; }
.rules-note { padding: 24rpx; background: #fff; border-radius: 18rpx; font-size: 24rpx; color: #8b939f; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.rules-note text { display: block; }
.note-title { color: #737d8b; font-weight: 500; margin-bottom: 8rpx; }
.page-note { display: block; margin-top: 24rpx; color: #a0a7b1; font-size: 22rpx; line-height: 1.8; }
.record-list { background: #fff; padding: 0 26rpx; border-radius: 20rpx; }
.record-row { display: flex; align-items: center; gap: 24rpx; padding: 26rpx 0; border-bottom: 1rpx solid #f0f2f5; }
.record-row:last-child { border-bottom: none; }
.record-content { flex: 1; min-width: 0; }
.record-title { display: block; font-size: 27rpx; line-height: 1.6; overflow-wrap: anywhere; }
.record-time, .record-balance { display: block; margin-top: 8rpx; color: #a0a7b1; font-size: 22rpx; }
.record-change { flex-shrink: 0; color: #b28449; font-size: 34rpx; font-weight: 600; }
.record-change.negative { color: #798694; }
.load-more { padding: 30rpx 0; text-align: center; color: #a0a7b1; font-size: 23rpx; }
</style>

View File

@@ -0,0 +1,230 @@
<template>
<view class="benefits-page">
<view v-if="loading && !loaded" class="page-skeleton" aria-label="正在加载会员权益">
<view class="skeleton skeleton-card" /><view class="skeleton skeleton-growth" />
<view class="skeleton-grid"><view v-for="n in 4" :key="n" class="skeleton skeleton-benefit" /></view>
</view>
<view v-else-if="error && !loaded" class="page-state">
<u-icon name="info-circle" size="40" color="#b4bac4" /><text class="state-title">权益加载失败</text>
<text class="state-description">请检查网络后重试</text><button class="retry-button" @click="load">重新加载</button>
</view>
<template v-else-if="loaded">
<view v-if="error" class="refresh-error" @click="load">刷新失败,点击重试</view>
<view v-if="grades.length">
<swiper class="grade-swiper" :current="selectedIndex" :duration="250" :disable-touch="grades.length < 2" @change="onGradeSwipe">
<swiper-item v-for="item in grades" :key="item.grade.id">
<MemberGradeCard :grade="item.grade" :benefit-count="item.benefits?.length || 0" :is-current="sameId(current.gradeId, item.grade.id)" />
</swiper-item>
</swiper>
<view v-if="grades.length > 1" class="swipe-hint"><text>左右滑动查看等级</text><text>{{ selectedIndex + 1 }} / {{ grades.length }}</text></view>
<view class="growth-card">
<view class="section-heading"><text class="section-title">我的成长</text><text class="section-caption">{{ current.grade?.gradeName || '暂无等级' }}</text></view>
<view class="experience-row"><text class="experience-number">{{ number(current.experience) }}</text><text class="experience-label">当前经验值</text></view>
<template v-if="current.nextGrade">
<view class="growth-track"><view class="growth-fill" :style="{ width: progress + '%' }" /></view>
<view class="growth-labels"><text>{{ number(growthBaseline) }}</text><text>{{ number(current.nextGrade.requiredExperience) }}</text></view>
<text v-if="Number(current.remainingExperience) > 0" class="growth-description">再获得 <text class="accent">{{ number(current.remainingExperience) }}</text> 经验值,即可升级为{{ current.nextGrade.gradeName }}</text>
<text v-else class="growth-description">已达到{{ current.nextGrade.gradeName }}的经验门槛,等级以系统更新结果为准</text>
</template>
<view v-else-if="current.highestGrade" class="highest-grade"><u-icon name="checkmark-circle" size="16" color="#a47a3b" /><text>已达到最高等级,尽享专属权益</text></view>
<text v-else class="growth-description">暂无升级信息</text>
<view class="growth-actions">
<view class="growth-action" @click="openExperience('tasks')"><u-icon name="level" size="17" color="#9a7545" /><text>经验值任务</text><u-icon name="arrow-right" size="11" color="#b4bac4" /></view>
<view class="growth-action" @click="openExperience('logs')"><u-icon name="list" size="17" color="#9a7545" /><text>经验值明细</text><u-icon name="arrow-right" size="11" color="#b4bac4" /></view>
</view>
</view>
<view class="benefits-section">
<view class="section-heading"><text class="section-title">专属权益 <text class="benefit-count">{{ selected.benefits?.length || 0 }}项</text></text><text class="section-caption">点击查看详情</text></view>
<view v-if="selected.benefits?.length" class="benefit-grid">
<view v-for="benefit in selected.benefits" :key="benefit.id" class="benefit-card" @click="detail = benefit">
<view class="benefit-topline"><view class="benefit-icon-wrap">
<image v-if="benefit.benefitLogo && !failedBenefitIcons[benefit.id]" class="benefit-icon" :src="benefit.benefitLogo" mode="aspectFit" @error="failedBenefitIcons[benefit.id] = true" />
<u-icon v-else name="gift" size="28" color="#ac844e" />
</view><u-icon name="arrow-right" size="12" color="#c4c8ce" /></view>
<text class="benefit-name">{{ benefit.benefitName }}</text><text class="benefit-summary">{{ summary(benefit) }}</text>
<text class="benefit-state" :class="{ owned: owned(benefit) }">{{ benefitState(benefit) }}</text>
</view>
</view>
<view v-else class="empty-benefits"><u-icon name="gift" size="36" color="#c1c6ce" /><text>该等级暂未配置权益</text><text class="state-description">可以切换其他等级查看</text></view>
</view>
<view class="page-notice"><text>权益说明</text><text>各等级权益独立配置,以当前等级实际享有的权益为准。具体适用条件与发放规则请查看权益详情。</text></view>
</view>
<view v-else class="page-state"><u-icon name="gift" size="40" color="#b4bac4" /><text class="state-title">暂无会员等级</text><text class="state-description">等级权益配置后将在这里展示</text></view>
</template>
<view v-if="detail" class="detail-overlay">
<view class="detail-backdrop" @click="detail = null" @touchmove.stop.prevent />
<view class="detail-sheet">
<view class="sheet-handle" /><view class="detail-header"><text>权益详情</text><view class="close-icon" aria-label="关闭权益详情" @click="detail = null"><u-icon name="close" size="20" color="#707783" /></view></view>
<scroll-view scroll-y class="detail-scroll">
<view class="detail-content">
<view class="detail-identity">
<image v-if="detail.benefitLogo && !failedBenefitIcons[detail.id]" class="detail-icon" :src="detail.benefitLogo" mode="aspectFit" @error="failedBenefitIcons[detail.id] = true" />
<u-icon v-else name="gift" size="36" color="#ac844e" /><text class="detail-title">{{ detail.benefitName }}</text>
<text class="benefit-state" :class="{ owned: owned(detail) }">{{ benefitState(detail) }}</text>
</view>
<view class="detail-rule"><text class="rule-label">权益规则</text><text>{{ rule(detail) }}</text></view>
<text class="description-title">使用说明</text><rich-text v-if="detail.benefitDesc" class="rich-description" :nodes="detailDescription" />
<text v-else class="state-description">暂无详细说明</text>
</view>
</scroll-view>
<view class="sheet-footer"><button class="confirm-button" @click="detail = null">我知道了</button></view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow, onPullDownRefresh, onUnload } from '@dcloudio/uni-app'
import { getCurrentMemberBenefits, getMemberGradeBenefits } from '@/api/members'
import { isLogin, navigateToLogin } from '@/utils/filters'
import MemberGradeCard from './components/MemberGradeCard.vue'
const loading = ref(false), loaded = ref(false), error = ref(false)
const current = ref({}), grades = ref([]), selectedId = ref(''), detail = ref(null)
const failedBenefitIcons = ref({})
let requestId = 0, ownerId = ''
const sameId = (a, b) => a != null && b != null && String(a) === String(b)
const selected = computed(() => grades.value.find(item => sameId(item.grade.id, selectedId.value)) || {})
const selectedIndex = computed(() => Math.max(0, grades.value.findIndex(item => sameId(item.grade.id, selectedId.value))))
const progress = computed(() => Math.max(0, Math.min(100, Number(current.value.upgradeProgress) || 0)))
const growthBaseline = computed(() => current.value.grade?.isDefault ? 0 : current.value.grade?.requiredExperience || 0)
const owned = benefit => (current.value.benefits || []).some(item => sameId(item.id, benefit.id))
function number(value) { return Math.max(0, Number(value) || 0).toLocaleString('en-US') }
function config(benefit) {
try { const value = typeof benefit.benefitConfig === 'string' ? JSON.parse(benefit.benefitConfig) : benefit.benefitConfig; return value && typeof value === 'object' ? value : {} } catch { return {} }
}
function summary(benefit) {
const value = config(benefit)
switch (benefit.benefitType) {
case 'POINT_MULTIPLIER': return `购物积分按 ${value.multiplier || 1} 倍计算`
case 'GIFT_POINT': return `首次达到等级赠送 ${number(value.giftPoint)} 积分`
case 'COUPON_PACKAGE': return value.grantRule === 'MONTHLY' ? `每月 ${value.grantDay || 1} 号发放优惠券` : value.grantRule === 'PERIOD' ? `每 ${value.periodMonths || 1} 个月发放优惠券` : '首次达到等级赠送优惠券'
case 'GRADE_BADGE': return '展示你的专属等级身份'
case 'EXCLUSIVE_CUSTOMER_SERVICE': return '享受会员专属客服服务'
default: return '查看权益介绍与适用说明'
}
}
function rule(benefit) {
const text = summary(benefit)
switch (benefit.benefitType) {
case 'POINT_MULTIPLIER': return `${text},同类权益取最高倍率,不叠加。`
case 'GIFT_POINT': return `${text},同一等级的该权益仅赠送一次。`
case 'COUPON_PACKAGE': return `${text}。每月不存在指定日期时取当月最后一天;按周期发放时首期立即发放,暂停期间未生成的历史周期不追发。具体券种和数量以实际发放为准。`
case 'CUSTOM': return '适用条件、参与方式及服务内容以以下权益介绍为准。'
default: return `${text},具体服务内容请查看以下说明。`
}
}
function benefitState(benefit) {
if (owned(benefit)) return '已享有'
return Number(selected.value.grade?.requiredExperience) > Number(current.value.grade?.requiredExperience || 0) ? '达到该等级可享' : '当前等级未包含'
}
const detailDescription = computed(() => String(detail.value?.benefitDesc || '').replace(/<img\b[^>]*>/gi, tag =>
tag.replace(/\sstyle\s*=\s*(?:"[^"]*"|'[^']*')/gi, '').replace(/<img\b/i, '<img style="max-width:100%;height:auto;display:block;"')))
function selectGrade(id) { selectedId.value = id; detail.value = null }
function openExperience(tab) { uni.navigateTo({ url: `/pages/mine/experience?tab=${tab}` }) }
function onGradeSwipe(event) {
// 初始化和刷新引发的程序切换无需回写,避免旧事件覆盖最新选择。
if (event.detail?.source !== 'touch') return
const item = grades.value[event.detail.current]
if (item) selectGrade(item.grade.id)
}
function resetMembership() {
requestId++; ownerId = ''; loaded.value = false; loading.value = false; error.value = false
current.value = {}; grades.value = []; selectedId.value = ''; detail.value = null; failedBenefitIcons.value = {}
}
async function load() {
const memberId = String(isLogin()?.id || '')
if (!memberId) { resetMembership(); navigateToLogin(); uni.stopPullDownRefresh(); return }
if (ownerId !== memberId) {
ownerId = memberId; loaded.value = false
current.value = {}; grades.value = []; selectedId.value = ''; detail.value = null; failedBenefitIcons.value = {}
}
const id = ++requestId
const stillCurrent = () => id === requestId && String(isLogin()?.id || '') === memberId
loading.value = true; error.value = false
try {
const [a, b] = await Promise.all([getCurrentMemberBenefits(), getMemberGradeBenefits()])
if (!stillCurrent()) return
if (!a.data?.success || !b.data?.success) throw new Error('load')
current.value = a.data.result || {}
grades.value = (b.data.result || []).filter(item => item.grade?.gradeState === 'OPEN')
const requested = selectedId.value || current.value.gradeId
selectedId.value = grades.value.find(item => sameId(item.grade.id, requested))?.grade.id || grades.value[0]?.grade.id || ''
detail.value = null; failedBenefitIcons.value = {}; loaded.value = true
} catch { if (stillCurrent()) error.value = true }
finally { if (id === requestId) { loading.value = false; uni.stopPullDownRefresh() } }
}
onShow(load)
onPullDownRefresh(load)
onUnload(() => { requestId++ })
</script>
<style scoped lang="scss">
.benefits-page { min-height: 100vh; box-sizing: border-box; background: #f5f6f9; padding: 24rpx 28rpx calc(36rpx + env(safe-area-inset-bottom)); color: #252b35; }
.grade-swiper { width: 100%; height: 299rpx; border-radius: 24rpx; overflow: hidden; }
.swipe-hint { display: flex; justify-content: space-between; margin-top: 14rpx; color: #a0a5af; font-size: 21rpx; }
.growth-card { margin-top: 24rpx; padding: 28rpx; background: #fff; border-radius: 20rpx; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 600; }
.section-caption { font-size: 22rpx; color: #959ba5; }
.experience-row { display: flex; align-items: baseline; gap: 12rpx; margin-top: 20rpx; }
.experience-number { font-size: 42rpx; font-weight: 600; }
.experience-label { font-size: 22rpx; color: #959ba5; }
.growth-track { margin-top: 20rpx; height: 10rpx; overflow: hidden; background: #f2eee8; border-radius: 8rpx; }
.growth-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #dfc59b, #ae8245); transition: width .25s; }
.growth-labels { display: flex; justify-content: space-between; font-size: 20rpx; color: #a0a5af; margin-top: 8rpx; }
.growth-description { display: block; margin-top: 16rpx; color: #777e88; font-size: 24rpx; line-height: 1.7; }
.accent { color: #a47a3b; font-weight: 600; }
.highest-grade { display: flex; align-items: center; gap: 10rpx; margin-top: 18rpx; color: #987343; font-size: 24rpx; }
.growth-actions { display: flex; margin-top: 24rpx; padding-top: 22rpx; border-top: 1rpx solid #f0f1f4; }
.growth-action { flex: 1; display: flex; justify-content: center; align-items: center; gap: 10rpx; padding: 8rpx 0; font-size: 24rpx; color: #777e88; }
.growth-action + .growth-action { border-left: 1rpx solid #eceef2; }
.benefits-section { margin-top: 36rpx; }
.benefit-count { color: #9a7b50; font-size: 24rpx; font-weight: 400; margin-left: 10rpx; }
.benefit-grid { margin-top: 20rpx; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20rpx; }
.benefit-card { background: #fff; padding: 24rpx; border-radius: 20rpx; display: flex; flex-direction: column; align-items: flex-start; }
.benefit-card:active { background: #fcfaf6; }
.benefit-topline { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.benefit-icon-wrap { width: 84rpx; height: 84rpx; background: #faf6ee; border-radius: 22rpx; display: flex; align-items: center; justify-content: center; }
.benefit-icon { width: 64rpx; height: 64rpx; }
.benefit-name { font-size: 27rpx; font-weight: 600; line-height: 1.5; margin-top: 18rpx; overflow-wrap: anywhere; }
.benefit-summary { margin: 8rpx 0 20rpx; color: #9298a2; font-size: 22rpx; line-height: 1.6; flex: 1; }
.benefit-state { display: inline-block; border-radius: 8rpx; padding: 5rpx 10rpx; background: #f4f5f7; color: #9298a2; font-size: 20rpx; line-height: 1.5; }
.benefit-state.owned { background: #f6eedf; color: #97713c; }
.empty-benefits { margin-top: 20rpx; padding: 48rpx 20rpx; background: #fff; border-radius: 20rpx; display: flex; align-items: center; flex-direction: column; gap: 16rpx; font-size: 26rpx; color: #777e88; }
.page-notice { margin-top: 32rpx; color: #a0a5ae; font-size: 22rpx; line-height: 1.8; }
.page-notice text { display: block; }
.page-notice text:first-child { margin-bottom: 8rpx; color: #858c97; font-weight: 500; }
.page-state { min-height: 65vh; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 22rpx; }
.state-title { font-size: 30rpx; color: #727985; }
.state-description { font-size: 24rpx; color: #a0a6af; }
.retry-button { font-size: 26rpx; color: #8d6836; background: #f1e6d4; padding: 0 36rpx; margin-top: 10rpx; border-radius: 40rpx; }
button::after { border: none; }
.refresh-error { padding: 16rpx; margin-bottom: 20rpx; background: #fff3df; color: #a27a42; font-size: 24rpx; border-radius: 12rpx; text-align: center; }
.skeleton { background: #e8ebef; border-radius: 20rpx; }
.skeleton-card { height: 299rpx; }
.skeleton-growth { height: 160rpx; margin: 24rpx 0; }
.skeleton-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20rpx; }
.skeleton-benefit { height: 270rpx; }
.detail-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; }
.detail-backdrop { position: absolute; inset: 0; background: rgba(20, 25, 34, .45); }
.detail-sheet { position: relative; width: 100%; background: #fff; border-radius: 32rpx 32rpx 0 0; overflow: hidden; }
.sheet-handle { width: 64rpx; height: 7rpx; margin: 16rpx auto 8rpx; background: #e1e4e9; border-radius: 8rpx; }
.detail-header { display: flex; justify-content: space-between; align-items: center; padding: 8rpx 20rpx 16rpx 32rpx; font-size: 28rpx; font-weight: 500; }
.close-icon { padding: 16rpx; }
.detail-scroll { height: 48vh; }
.detail-content { padding: 8rpx 32rpx 32rpx; }
.detail-identity { display: flex; flex-direction: column; align-items: center; gap: 16rpx; padding: 12rpx 0 28rpx; }
.detail-icon { width: 96rpx; height: 96rpx; }
.detail-title { font-size: 34rpx; font-weight: 600; text-align: center; }
.detail-rule { background: #faf6ef; border-radius: 16rpx; padding: 24rpx; font-size: 25rpx; color: #987445; line-height: 1.8; }
.detail-rule text { display: block; }
.rule-label { font-weight: 600; margin-bottom: 8rpx; }
.description-title { display: block; font-size: 28rpx; font-weight: 600; margin: 28rpx 0 20rpx; }
.rich-description { display: block; font-size: 26rpx; line-height: 1.8; color: #737a85; overflow-wrap: anywhere; }
.sheet-footer { padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #f2f3f5; }
.confirm-button { background: #f2e5cf; color: #805d2d; font-size: 28rpx; border-radius: 40rpx; line-height: 80rpx; }
</style>

79
pages/mine/paidMember.vue Normal file
View 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>

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

View File

@@ -346,7 +346,7 @@
</div> </div>
<div> <div>
<u-row> <u-row>
<u-col :span="6">活动优惠</u-col> <u-col :span="6">活动优惠(含会员优惠)</u-col>
<u-col :span="6" class="tr tipsColor" textAlign="right"> <u-col :span="6" class="tr tipsColor" textAlign="right">
<span v-if="orderMessage.priceDetailDTO.discountPrice" <span v-if="orderMessage.priceDetailDTO.discountPrice"
>-¥{{unitPrice(orderMessage.priceDetailDTO.discountPrice) >-¥{{unitPrice(orderMessage.priceDetailDTO.discountPrice)

View File

@@ -114,7 +114,7 @@
<u-popup class="cancel-popup" v-model:show="cancelShow" mode="bottom" length="60%"> <u-popup class="cancel-popup" v-model:show="cancelShow" mode="bottom" length="60%">
<view class="header">取消订单</view> <view class="header">取消订单</view>
<view class="body"> <view class="body">
<view class="title">取消订单后,本单享有的优惠可能会一并取消,是否继续?</view> <view class="title">{{ cancelIsPickup ? '此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。是否继续?' : '取消订单后,本单享有的优惠可能会一并取消,是否继续?' }}</view>
<view> <view>
<u-radio-group v-model="reason"> <u-radio-group v-model="reason">
<view class="value"> <view class="value">
@@ -299,7 +299,10 @@ function getOrderItemImage(goods: any, order: any, index: number) {
return parseGoodsImageUrl(image) return parseGoodsImageUrl(image)
} }
const cancelIsPickup = ref(false)
function onCancel(sn: string) { function onCancel(sn: string) {
cancelIsPickup.value = !!navList.flatMap(tab => tab.orderList).find(order => order.sn === sn)?.pickupCardId
orderSn.value = sn orderSn.value = sn
cancelShow.value = true cancelShow.value = true
uni.showLoading({ title: '加载中' }) uni.showLoading({ title: '加载中' })

View File

@@ -257,7 +257,7 @@
<u-popup class="cancel-popup" v-model:show="cancelShow" mode="bottom" length="60%"> <u-popup class="cancel-popup" v-model:show="cancelShow" mode="bottom" length="60%">
<view class="header">取消订单</view> <view class="header">取消订单</view>
<view class="body"> <view class="body">
<view class="title">取消订单后,本单享有的优惠可能会一并取消,是否继续?</view> <view class="title">{{ order.pickupCardId ? '此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。是否继续?' : '取消订单后,本单享有的优惠可能会一并取消,是否继续?' }}</view>
<view> <view>
<u-radio-group v-model="reason"> <u-radio-group v-model="reason">
<view class="value"> <view class="value">

View File

@@ -70,7 +70,7 @@
<GoodsSwiper id="main1" :res="imgList" :video="goodsDetail.goodsVideo" /> <GoodsSwiper id="main1" :res="imgList" :video="goodsDetail.goodsVideo" />
<!-- 促销活动条(秒杀/限时直降/拼团等,M-02 含 E_COUPON) --> <!-- 促销活动条(秒杀/拼团等,M-02 含 E_COUPON) -->
<PromotionAssembleLayout v-if="PromotionList" :detail="goodsDetail" :res="PromotionList" /> <PromotionAssembleLayout v-if="PromotionList" :detail="goodsDetail" :res="PromotionList" />
<view class="card-box top-radius-0" id="main2"> <view class="card-box top-radius-0" id="main2">
@@ -139,6 +139,7 @@
</view> </view>
</view> </view>
<view v-if="paidQuote && !isSeckill && !isGroup" class="card-box"><PaidMemberPrice :quote="paidQuote" /></view>
<!-- 促销/领券入口 --> <!-- 促销/领券入口 -->
<view class="card-box"> <view class="card-box">
<view class="card-flex" @click="shutMask(1)"> <view class="card-flex" @click="shutMask(1)">
@@ -298,6 +299,8 @@ import PromotionAssembleLayout from './product/promotion/-promotion-assemble-pro
import PromotionAssembleListLayout from './product/promotion/-promotion-assemble-list' import PromotionAssembleListLayout from './product/promotion/-promotion-assemble-list'
import PromotionCoupon from './product/promotion/-promotion-coupon' import PromotionCoupon from './product/promotion/-promotion-coupon'
import GoodsIntro from './product/goods/-goods-intro' import GoodsIntro from './product/goods/-goods-intro'
import PaidMemberPrice from '@/components/paid-member-price/index.vue'
import { getPaidMemberQuotes } from '@/api/paid-member'
import GoodsRecommend from './product/goods/-goods-recommend' import GoodsRecommend from './product/goods/-goods-recommend'
import storeLayout from './product/shop/-shop' import storeLayout from './product/shop/-shop'
import Evaluation from './product/evaluation/-evaluation' import Evaluation from './product/evaluation/-evaluation'
@@ -338,6 +341,19 @@ const scrollId = ref("1")
const scrollFlag = ref(true) const scrollFlag = ref(true)
const current = ref("1") const current = ref("1")
const goodsDetail = ref<any>({}) const goodsDetail = ref<any>({})
const paidQuote = ref<any>(null)
let paidQuoteRequest = 0
async function loadPaidQuote() {
const id = ++paidQuoteRequest, memberId = String(isLogin()?.id || ''), skuId = goodsDetail.value?.id
paidQuote.value = null
if (!memberId || !skuId) return
try {
const res = await getPaidMemberQuotes([skuId])
if (id === paidQuoteRequest && String(isLogin()?.id || '') === memberId && res.data?.success) paidQuote.value = res.data.result?.[skuId] || null
} catch {}
}
watch(() => [goodsDetail.value?.id, store.state.userInfo?.id, store.state.hasLogin], loadPaidQuote)
onShow(loadPaidQuote)
const goodsSpec = ref<any>("") const goodsSpec = ref<any>("")
const imgList = ref<any[]>([]) const imgList = ref<any[]>([])
const favorite = ref(false) const favorite = ref(false)

View File

@@ -5,7 +5,6 @@
class="showBox" class="showBox"
v-if=" v-if="
promotion.__key == 'SECKILL' || promotion.__key == 'SECKILL' ||
promotion.__key == 'FLASH_DISCOUNT' ||
promotion.__key == 'GROUPBUY' || promotion.__key == 'GROUPBUY' ||
promotion.__key == 'PINTUAN' promotion.__key == 'PINTUAN'
" "
@@ -37,29 +36,6 @@
</view> </view>
</view> </view>
<!-- 限时直降 -->
<view class="u-group-flex-left" v-if="promotion.__key == 'FLASH_DISCOUNT'">
<span
class="u-group-flex-left-span"
v-if="detail.promotionPrice != undefined"
>
¥
<span class="flex-price">
{{ goodsFormatPrice(detail.promotionPrice)[0] }}.{{
goodsFormatPrice(detail.promotionPrice)[1]
}}</span
>
</span>
<view class="u-group-flex" v-if="detail.price != undefined">
<span class="old-price"
>¥{{ goodsFormatPrice(detail.price)[0] }}.{{
goodsFormatPrice(detail.price)[1]
}}</span
>
<view class="promotion">限时直降</view>
</view>
</view>
<!-- 团购 --> <!-- 团购 -->
<view class="u-group-flex-left" v-if="promotion.__key == 'GROUPBUY'"> <view class="u-group-flex-left" v-if="promotion.__key == 'GROUPBUY'">
<span class="u-group-flex-left-span"> <span class="u-group-flex-left-span">

View File

@@ -15,7 +15,7 @@
<text v-else-if="item.scopeType == 'PORTION_GOODS'">部分商品</text> <text v-else-if="item.scopeType == 'PORTION_GOODS'">部分商品</text>
<text v-else>{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text> <text v-else>{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text>
</view> </view>
<text>满{{ unitPrice(item.consumeThreshold) }}可用</text> <text>{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '可用' : '无门槛使用' }}</text>
</view> </view>
<view class="lingqu-btn" @tap="getCoupon(item, index)"> <view class="lingqu-btn" @tap="getCoupon(item, index)">
<view class="lingqu-text" :class="{ cur: yhqFlag[index] }"> <view class="lingqu-text" :class="{ cur: yhqFlag[index] }">

View File

@@ -40,26 +40,6 @@
</view> </view>
</view> </view>
<view v-if="getPromType(prom) == 'FLASH_DISCOUNT'">
<view class="res_prom_item">
<view class="deg_tag">限时直降</view>
<text class="pro-text">{{ props.res[prom].promotionName || props.res[prom].title || '限时直降活动' }}</text>
</view>
</view>
<view v-if="getPromType(prom) == 'NTH_ITEM_DISCOUNT'">
<view class="res_prom_item">
<view class="deg_tag">第N件优惠</view>
<text class="pro-text">
第{{ props.res[prom].nthNum || 'N' }}件
<text v-if="props.res[prom].discountType === 'FREE'">免费</text>
<text v-else-if="props.res[prom].discountType === 'HALF'">半价</text>
<text v-else-if="props.res[prom].discountValue">享{{ props.res[prom].discountValue }}折</text>
<text v-else>优惠</text>
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'POINTS_GOODS'"> <view v-if="getPromType(prom) == 'POINTS_GOODS'">
<view class="res_prom_item"> <view class="res_prom_item">
<view class="deg_tag">积分活动</view> <view class="deg_tag">积分活动</view>
@@ -93,7 +73,9 @@ const props = defineProps<{
const promotionKeys = computed(() => { const promotionKeys = computed(() => {
if (!props.res || typeof props.res !== 'object') return [] if (!props.res || typeof props.res !== 'object') return []
return Object.keys(props.res).filter((key) => getPromType(key) !== 'COUPON') return Object.keys(props.res).filter((key) =>
['FULL_DISCOUNT', 'PINTUAN', 'SECKILL', 'POINTS_GOODS', 'KANJIA'].includes(getPromType(key))
)
}) })
function getPromType(key: string) { function getPromType(key: string) {

View File

@@ -2,18 +2,6 @@
<view> <view>
<view v-for="(promotionItem, promotionIndex) in promotion" :key="promotionIndex" class="promotion_row" @click="shutMask(1)"> <view v-for="(promotionItem, promotionIndex) in promotion" :key="promotionIndex" class="promotion_row" @click="shutMask(1)">
<view v-if="res != null" v-for="(item, index) in Object.keys(res)" :key="index"> <view v-if="res != null" v-for="(item, index) in Object.keys(res)" :key="index">
<div class="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'FLASH_DISCOUNT'">
<div class="flex">
<view class="deg_tag">{{ promotionItem.title }}</view>
<div class="text proText">{{ res[item].promotionName || res[item].title || '限时直降' }}</div>
</div>
</div>
<div class="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'NTH_ITEM_DISCOUNT'">
<div class="flex">
<view class="deg_tag">{{ promotionItem.title }}</view>
<div class="text proText">{{ res[item].promotionName || res[item].title || '第N件优惠' }}</div>
</div>
</div>
<div class="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'FULL_DISCOUNT'"> <div class="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'FULL_DISCOUNT'">
<!-- 满减,折扣 --> <!-- 满减,折扣 -->
<div class="flex"> <div class="flex">

View File

@@ -7,14 +7,6 @@ const promotion = [
title: "砍价活动", title: "砍价活动",
value: "KANJIA", value: "KANJIA",
}, },
{
title: "限时直降",
value: "FLASH_DISCOUNT",
},
{
title: "第N件优惠",
value: "NTH_ITEM_DISCOUNT",
},
{ {
title: "团购", title: "团购",
value: "GROUPBUY", value: "GROUPBUY",

View File

@@ -96,7 +96,7 @@
<view class="xian"></view> <view class="xian"></view>
<view class="text"> <view class="text">
<text>{{ "领取优惠券" }}</text> <text>{{ "领取优惠券" }}</text>
<text>满{{unitPrice(item.consumeThreshold) }}元可用</text> <text>{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -36,11 +36,12 @@
}}</span }}</span
>元</view >元</view
> >
<view <view v-if="item.consumeThreshold > 0"
>满<span style="color: red; font-size: 32rpx" >满<span style="color: red; font-size: 32rpx"
>{{unitPrice(item.consumeThreshold) }}元</span >{{unitPrice(item.consumeThreshold) }}元</span
>可用</view >可用</view
> >
<view v-else>无门槛使用</view>
<view v-if="item.scopeType == 'ALL' && item.storeId == '0'" <view v-if="item.scopeType == 'ALL' && item.storeId == '0'"
>全平台</view >全平台</view
> >

View File

@@ -21,7 +21,7 @@
<text class="coupon-value">{{ unitPrice(item.price) }}</text> <text class="coupon-value">{{ unitPrice(item.price) }}</text>
</template> </template>
</view> </view>
<view class="coupon-threshold">满{{ unitPrice(item.consumeThreshold) }}元可用</view> <view class="coupon-threshold">{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</view>
</view> </view>
<view class="coupon-right"> <view class="coupon-right">
<view class="coupon-name"> <view class="coupon-name">

View File

@@ -1,14 +1,21 @@
<template> <template>
<view class="user-page" :style="themeStyle"> <view class="user-page" :style="themeStyle">
<!-- 顶部沉浸式头部区域 --> <!-- 顶部沉浸式头部区域 -->
<view class="hero-header" :style="{ paddingTop: headerPaddingTop + 'px' }"> <view class="hero-header" :style="heroHeaderStyle">
<!-- 用户信息区(沉降至胶囊安全区下方,舒展大气) --> <image
v-if="gradeBackgroundUrl && !gradeBackgroundFailed"
:key="gradeBackgroundUrl"
class="grade-background"
:class="{ 'is-loaded': gradeBackgroundLoaded }"
:src="gradeBackgroundUrl"
mode="aspectFill"
@load="gradeBackgroundLoaded = true"
@error="onGradeBackgroundError"
/>
<!-- 用户信息区保持在微信胶囊安全区下方 -->
<view class="user-profile" @click="userDetail"> <view class="user-profile" @click="userDetail">
<view class="avatar-container"> <view class="avatar-container">
<image class="avatar-img" :src="userInfo.face || userImage" mode="aspectFill"></image> <image class="avatar-img" :src="userInfo.face || userImage" mode="aspectFill"></image>
<view class="vip-tag-icon" v-if="userInfo.id">
<u-icon name="level" size="10" color="#ffffff"></u-icon>
</view>
</view> </view>
<view class="user-detail-info"> <view class="user-detail-info">
@@ -16,17 +23,19 @@
<text class="user-nickname">{{ userInfo.id ? userInfo.nickName : '登录 / 注册' }}</text> <text class="user-nickname">{{ userInfo.id ? userInfo.nickName : '登录 / 注册' }}</text>
</view> </view>
<view class="user-sub-row"> <view class="user-sub-row">
<view class="badge-tag" v-if="userInfo.id"> <view class="badge-tag" v-if="userInfo.id" @click.stop="navigateTo('/pages/mine/memberBenefits')">
<text>普通会员</text> <image v-if="gradeIconUrl && !gradeIconFailed" :key="gradeIconUrl" class="grade-icon" :src="gradeIconUrl" mode="aspectFit" @error="gradeIconFailed = true" />
<u-icon v-else name="level" size="16" :color="memberHeaderColor"></u-icon>
<text class="grade-name">{{ memberGradeName }}</text>
</view> </view>
<text class="user-sub-text" v-else> <text class="user-sub-text" v-else>
登录享受更多会员特权与折扣 登录享受更多会员权益
</text> </text>
</view> </view>
</view> </view>
<view class="profile-arrow"> <view class="profile-arrow">
<u-icon name="arrow-right" size="14" color="rgba(255, 255, 255, 0.75)"></u-icon> <u-icon name="arrow-right" size="14" :color="memberHeaderColor"></u-icon>
</view> </view>
</view> </view>
@@ -60,6 +69,13 @@
<!-- 下方主内容卡片区域 --> <!-- 下方主内容卡片区域 -->
<view class="main-container"> <view class="main-container">
<navigator v-if="showPaidMemberEntry" class="paid-entry" :style="{ color: paidEntryColor }" url="/pages/mine/paidMember" hover-class="none">
<image v-if="paidEntryBackground" class="entry-bg" :src="paidEntryBackground" mode="aspectFill" />
<view class="entry-content">
<view><text class="entry-title">{{ paidEntryTitle }}</text><text class="entry-subtitle">{{ paidEntrySubtitle }}</text></view>
<u-icon name="arrow-right" size="14" :color="paidEntryColor" />
</view>
</navigator>
<!-- 我的订单卡片 --> <!-- 我的订单卡片 -->
<view class="card-box order-box"> <view class="card-box order-box">
<view class="card-title-bar"> <view class="card-title-bar">
@@ -115,7 +131,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
onLoad, onLoad,
onShow, onShow,
@@ -123,6 +139,7 @@ import {
onNavigationBarButtonTap, onNavigationBarButtonTap,
} from '@dcloudio/uni-app' } from '@dcloudio/uni-app'
import tool from '@/pages/tabbar/user/utils/tool.vue' import tool from '@/pages/tabbar/user/utils/tool.vue'
import { getPaidMember } from '@/api/paid-member'
import { getCouponsNum, getFootprintNum } from '@/api/members.js' import { getCouponsNum, getFootprintNum } from '@/api/members.js'
import { getUserWallet } from '@/api/members' import { getUserWallet } from '@/api/members'
import configs from '@/config/config' import configs from '@/config/config'
@@ -130,6 +147,13 @@ import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import { isLogin, navigateToLogin, unitPrice } from '@/utils/filters.js' import { isLogin, navigateToLogin, unitPrice } from '@/utils/filters.js'
import { getCurrentMemberBenefits } from '@/api/members'
const memberGrade = ref<any>({})
const gradeOwnerId = ref('')
const gradeBackgroundLoaded = ref(false)
const gradeBackgroundFailed = ref(false)
const gradeIconFailed = ref(false)
let gradeRequestId = 0
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
const lightColor = computed(() => store.getters.lightColor) const lightColor = computed(() => store.getters.lightColor)
@@ -138,12 +162,116 @@ const aiderLightColor = computed(() => store.getters.aiderLightColor)
const userImage = configs.defaultUserPhoto const userImage = configs.defaultUserPhoto
const userInfo = ref<any>({}) const userInfo = ref<any>({})
const paidMemberInfo = ref<any>(null)
let paidMemberRequestId = 0
const showPaidMemberEntry = computed(() => !!paidMemberInfo.value?.config
&& !!(paidMemberInfo.value.config.enabled || paidMemberInfo.value.active || paidMemberInfo.value.status === 'EXPIRED'))
const paidEntryTitle = computed(() => paidMemberInfo.value?.active
? paidMemberInfo.value.config?.openedText || '我的付费会员'
: paidMemberInfo.value?.config?.unopenedText || '开通付费会员,享专属权益')
const paidEntryColor = computed(() => (paidMemberInfo.value?.active
? paidMemberInfo.value.config?.openedFontColor
: paidMemberInfo.value?.config?.unopenedFontColor) || '#f4dfb9')
const paidEntryBackground = computed(() => paidMemberInfo.value?.active
? paidMemberInfo.value.config?.openedBackground
: paidMemberInfo.value?.config?.unopenedBackground)
const paidEntrySubtitle = computed(() => paidMemberInfo.value?.active
? '有效期至 ' + paidMemberInfo.value.endTime
: paidMemberInfo.value?.status === 'EXPIRED' ? '会员已到期,续费继续享权益' : '开通享商品优惠与专属权益')
const couponNum = ref<string | number>('') const couponNum = ref<string | number>('')
const footNum = ref<string | number>('') const footNum = ref<string | number>('')
const walletNum = ref<string | number>('') const walletNum = ref<string | number>('')
const currentGrade = computed(() =>
userInfo.value.id && gradeOwnerId.value === String(userInfo.value.id) ? memberGrade.value : {}
)
const memberGradeName = computed(() => currentGrade.value.gradeName || '普通会员')
const gradeBackgroundUrl = computed(() => currentGrade.value.gradeBackground || '')
const gradeIconUrl = computed(() => currentGrade.value.gradeImage || '')
const memberHeaderColor = computed(() => {
if (!gradeBackgroundUrl.value || !gradeBackgroundLoaded.value || gradeBackgroundFailed.value) return '#ffffff'
const color = currentGrade.value.gradeFontColor || ''
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#333333'
})
const heroHeaderStyle = computed(() => ({
paddingTop: headerPaddingTop.value + 'px',
'--member-header-color': memberHeaderColor.value,
}))
watch(gradeBackgroundUrl, () => {
gradeBackgroundLoaded.value = false
gradeBackgroundFailed.value = false
}, { flush: 'sync' })
watch(gradeIconUrl, () => { gradeIconFailed.value = false }, { flush: 'sync' })
function resetMemberGrade() {
gradeRequestId++
gradeOwnerId.value = ''
memberGrade.value = {}
gradeBackgroundLoaded.value = false
gradeBackgroundFailed.value = false
gradeIconFailed.value = false
}
/** 个人中心显示时读取会员配置及资格;退出或切换账号后丢弃旧请求结果。 */
async function loadPaidMemberInfo() {
const requestId = ++paidMemberRequestId
const memberId = String(userInfo.value.id || '')
paidMemberInfo.value = null
if (!memberId) return
try {
const res = await getPaidMember()
if (requestId !== paidMemberRequestId
|| String(userInfo.value.id || '') !== memberId
|| String((isLogin() || {}).id || '') !== memberId) return
if (!res.data?.success) throw new Error(res.data?.message || '会员服务返回异常')
paidMemberInfo.value = res.data.result || null
} catch (error: any) {
if (requestId === paidMemberRequestId) {
console.warn('[付费会员] 入口加载失败', error?.message || '请求失败')
}
}
}
function onGradeBackgroundError() {
gradeBackgroundFailed.value = true
gradeBackgroundLoaded.value = false
}
async function loadMemberGrade() {
const memberId = String(userInfo.value.id || '')
if (!memberId) { resetMemberGrade(); return }
if (gradeOwnerId.value !== memberId) resetMemberGrade()
const requestId = ++gradeRequestId
const stillCurrent = () => requestId === gradeRequestId
&& String((isLogin() || {}).id || '') === memberId
&& String(userInfo.value.id || '') === memberId
try {
const res = await getCurrentMemberBenefits()
if (!stillCurrent()) return
gradeOwnerId.value = memberId
memberGrade.value = res.data?.success ? (res.data.result?.grade || {}) : {}
} catch {
if (stillCurrent()) resetMemberGrade()
}
}
// 保留页在退出登录/切换账号时立即清除上一账号的等级展示。
watch(() => [store.state.userInfo?.id, store.state.hasLogin], () => {
const account = store.state.hasLogin === false ? {} : (isLogin() || {})
if (String(account.id || '') !== String(userInfo.value.id || '')) {
userInfo.value = account
resetMemberGrade()
paidMemberRequestId++
paidMemberInfo.value = null
walletNum.value = 0
couponNum.value = 0
footNum.value = 0
}
})
// 动态计算顶部安全留白,避免顶到灵动岛/状态栏 // 动态计算顶部安全留白,避免顶到灵动岛/状态栏
const headerPaddingTop = ref<number>(88) const headerPaddingTop = ref<number>(80)
function initSafeTop() { function initSafeTop() {
try { try {
@@ -152,17 +280,17 @@ function initSafeTop() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
const menu = uni.getMenuButtonBoundingClientRect() const menu = uni.getMenuButtonBoundingClientRect()
if (menu && menu.bottom) { if (menu && menu.bottom) {
// 头像整行落到微信胶囊下方,预留 16px 呼吸空隙,彻底消除压迫感 // 胶囊下方保留 8px,减少空白且不遮挡用户信息
headerPaddingTop.value = menu.bottom + 16 headerPaddingTop.value = menu.bottom + 8
} else { } else {
headerPaddingTop.value = statusBar + 54 headerPaddingTop.value = statusBar + 44
} }
// #endif // #endif
// #ifndef MP-WEIXIN // #ifndef MP-WEIXIN
headerPaddingTop.value = statusBar + 32 headerPaddingTop.value = statusBar + 20
// #endif // #endif
} catch (e) { } catch (e) {
headerPaddingTop.value = 88 headerPaddingTop.value = 80
} }
} }
@@ -170,28 +298,26 @@ onLoad(() => {
initSafeTop() initSafeTop()
}) })
onShow(() => { async function refreshUserPage() {
userInfo.value = isLogin() || {} userInfo.value = isLogin() || {}
if (isLogin('auth')) { try {
getUserOrderNum() if (isLogin('auth')) {
} else { await Promise.allSettled([loadMemberGrade(), loadPaidMemberInfo(), getUserOrderNum()])
walletNum.value = 0 } else {
couponNum.value = 0 resetMemberGrade()
footNum.value = 0 paidMemberRequestId++
} paidMemberInfo.value = null
}) walletNum.value = 0
couponNum.value = 0
onPullDownRefresh(() => { footNum.value = 0
userInfo.value = isLogin() || {} }
if (isLogin('auth')) { } finally {
getUserOrderNum()
} else {
walletNum.value = 0
couponNum.value = 0
footNum.value = 0
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
} }
}) }
onShow(refreshUserPage)
onPullDownRefresh(refreshUserPage)
// #ifndef MP // #ifndef MP
onNavigationBarButtonTap((e) => { onNavigationBarButtonTap((e) => {
@@ -212,6 +338,7 @@ function userDetail() {
} }
async function getUserOrderNum() { async function getUserOrderNum() {
const memberId = String(userInfo.value.id || '')
try { try {
// Each request can fail independently; a missing wallet must not break the other counts. // Each request can fail independently; a missing wallet must not break the other counts.
const results = await Promise.allSettled([ const results = await Promise.allSettled([
@@ -220,20 +347,25 @@ async function getUserOrderNum() {
getUserWallet(), getUserWallet(),
]) ])
// The user may have logged out while these requests were running. // The user may have logged out while these requests were running.
if (!isLogin('auth')) return if (String((isLogin() || {}).id || '') !== memberId || String(userInfo.value.id || '') !== memberId) return
const targets = [couponNum, footNum, walletNum] const targets = [couponNum, footNum, walletNum]
results.forEach((result, index) => { results.forEach((result, index) => {
if (result.status !== 'fulfilled' || !result.value?.data?.success) return if (result.status !== 'fulfilled' || !result.value?.data?.success) return
const data = result.value.data.result const data = result.value.data.result
targets[index].value = index === 2 ? (data?.memberWallet ?? 0) : (data ?? 0) targets[index].value = index === 2 ? (data?.memberWallet ?? 0) : (data ?? 0)
}) })
} finally { } catch {
uni.stopPullDownRefresh() // 等级与资产请求独立,接口异常不影响页面其它内容。
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.paid-entry { position: relative; overflow: hidden; border-radius: 20rpx; background: linear-gradient(110deg, #423c36, #756149); margin-bottom: 20rpx; }
.entry-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
.entry-content { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 20rpx; padding: 25rpx 28rpx; }
.entry-title { font-size: 27rpx; font-weight: 600; display: block; }
.entry-subtitle { font-size: 21rpx; opacity: .75; display: block; margin-top: 8rpx; }
.user-page { .user-page {
min-height: 100vh; min-height: 100vh;
background-color: #f5f6f9; background-color: #f5f6f9;
@@ -248,19 +380,35 @@ async function getUserOrderNum() {
background: linear-gradient(175deg, #e53238 0%, #eb4448 45%, #ff5e62 100%); background: linear-gradient(175deg, #e53238 0%, #eb4448 45%, #ff5e62 100%);
padding-left: 28rpx; padding-left: 28rpx;
padding-right: 28rpx; padding-right: 28rpx;
padding-bottom: 64rpx; padding-bottom: 44rpx;
border-bottom-left-radius: 36rpx; border-bottom-left-radius: 36rpx;
border-bottom-right-radius: 36rpx; border-bottom-right-radius: 36rpx;
/* CSS 静态兜底:状态栏高度 + 充足的沉降留白 */ /* 静态兜底保留状态栏与导航操作区域 */
padding-top: calc(var(--status-bar-height, 44px) + 88rpx); padding-top: calc(var(--status-bar-height, 44px) + 72rpx);
box-sizing: border-box; box-sizing: border-box;
overflow: hidden;
} }
/* 用户信息行(移到胶囊下方,拥有充裕横纵向空间) */ .grade-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.grade-background.is-loaded {
opacity: 1;
}
/* 紧凑用户信息行 */
.user-profile { .user-profile {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8rpx 6rpx; padding: 4rpx 6rpx;
transition: opacity 0.2s; transition: opacity 0.2s;
&:active { &:active {
@@ -272,8 +420,8 @@ async function getUserOrderNum() {
flex-shrink: 0; flex-shrink: 0;
.avatar-img { .avatar-img {
width: 124rpx; width: 104rpx;
height: 124rpx; height: 104rpx;
border-radius: 50%; border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.92); border: 4rpx solid rgba(255, 255, 255, 0.92);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.16); box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.16);
@@ -281,19 +429,6 @@ async function getUserOrderNum() {
display: block; display: block;
} }
.vip-tag-icon {
position: absolute;
right: -2rpx;
bottom: 2rpx;
width: 34rpx;
height: 34rpx;
background: linear-gradient(135deg, #f59e0b, #d97706);
border: 2rpx solid #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
} }
.user-detail-info { .user-detail-info {
@@ -306,9 +441,9 @@ async function getUserOrderNum() {
align-items: center; align-items: center;
.user-nickname { .user-nickname {
font-size: 38rpx; font-size: 34rpx;
font-weight: 700; font-weight: 700;
color: #ffffff; color: var(--member-header-color, #ffffff);
letter-spacing: 0.5rpx; letter-spacing: 0.5rpx;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -317,25 +452,46 @@ async function getUserOrderNum() {
} }
.user-sub-row { .user-sub-row {
margin-top: 12rpx; margin-top: 8rpx;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12rpx; gap: 12rpx;
.badge-tag { .badge-tag {
display: inline-flex;
align-items: center;
gap: 6rpx;
max-width: 100%;
min-height: 40rpx;
box-sizing: border-box;
font-size: 20rpx; font-size: 20rpx;
color: #ffffff; color: var(--member-header-color, #ffffff);
background: rgba(255, 255, 255, 0.22); background: rgba(255, 255, 255, 0.22);
padding: 3rpx 14rpx; padding: 2rpx 12rpx 2rpx 6rpx;
border-radius: 20rpx; border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35); border: 1rpx solid rgba(255, 255, 255, 0.35);
font-weight: 500; font-weight: 500;
.grade-icon {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
display: block;
}
.grade-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.user-sub-text { .user-sub-text {
font-size: 23rpx; font-size: 23rpx;
color: rgba(255, 255, 255, 0.88); color: var(--member-header-color, #ffffff);
opacity: 0.88;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -350,12 +506,13 @@ async function getUserOrderNum() {
} }
} }
/* 核心资产栏(沉浸在红色背景中) */ /* 核心资产栏 */
.hero-assets { .hero-assets {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: 44rpx; margin-top: 24rpx;
padding: 0 12rpx; padding: 0 12rpx;
.asset-col { .asset-col {
@@ -374,7 +531,7 @@ async function getUserOrderNum() {
.asset-number-row { .asset-number-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
color: #ffffff; color: var(--member-header-color, #ffffff);
.symbol { .symbol {
font-size: 22rpx; font-size: 22rpx;
@@ -384,7 +541,7 @@ async function getUserOrderNum() {
} }
.num { .num {
font-size: 40rpx; font-size: 36rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 0.5rpx; letter-spacing: 0.5rpx;
} }
@@ -404,9 +561,10 @@ async function getUserOrderNum() {
} }
.asset-title { .asset-title {
margin-top: 10rpx; margin-top: 8rpx;
font-size: 24rpx; font-size: 24rpx;
color: rgba(255, 255, 255, 0.88); color: var(--member-header-color, #ffffff);
opacity: 0.88;
line-height: 1; line-height: 1;
} }
} }
@@ -414,7 +572,8 @@ async function getUserOrderNum() {
.asset-sep { .asset-sep {
width: 2rpx; width: 2rpx;
height: 38rpx; height: 38rpx;
background-color: rgba(255, 255, 255, 0.22); background-color: var(--member-header-color, #ffffff);
opacity: 0.22;
} }
} }