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