mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-11-06 17:09:01 +08:00
feat(member): 完善移动端等级权益与经验任务
This commit is contained in:
@@ -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 }) }
|
||||
|
||||
70
pages.json
70
pages.json
@@ -13,16 +13,16 @@
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
|
||||
|
||||
{
|
||||
"path": "pages/tabbar/home/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "首页",
|
||||
"navigationStyle": "custom", // 隐藏系统导航栏
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh":true
|
||||
"enablePullDownRefresh":true
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path":"pages/tabbar/screen/fullScreen",
|
||||
"style": {
|
||||
@@ -63,13 +63,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "pages/navigation/search/searchPage",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索",
|
||||
"navigationStyle": "custom",
|
||||
|
||||
|
||||
"app-plus": {
|
||||
//app页面不显示滚动条
|
||||
"scrollIndicator": "none",
|
||||
@@ -89,11 +89,11 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "pages/tabbar/home/web-view",
|
||||
"style": {
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -173,7 +173,9 @@
|
||||
{
|
||||
"root": "pages/mine",
|
||||
"pages": [
|
||||
|
||||
{"path":"memberBenefits","style":{"navigationBarTitleText":"会员权益","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
|
||||
{"path":"experience","style":{"navigationBarTitleText":"我的经验","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
|
||||
|
||||
{
|
||||
"path": "signIn",
|
||||
"style": {
|
||||
@@ -379,7 +381,7 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "deposit/info",
|
||||
"style": {
|
||||
@@ -432,8 +434,8 @@
|
||||
"navigationBarTitleText": "面容登录"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
{
|
||||
"path": "set/securityCenter/editPassword",
|
||||
"style": {
|
||||
@@ -443,13 +445,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "set/securityCenter/bindMobile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "绑定手机号",
|
||||
"app-plus": {
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -461,7 +463,7 @@
|
||||
"enablePullDownRefresh": true,
|
||||
"app-plus": {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -471,7 +473,7 @@
|
||||
"enablePullDownRefresh": true,
|
||||
"app-plus": {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -619,9 +621,9 @@
|
||||
// 禁止页面滚动
|
||||
"scrollIndicator": "none",
|
||||
"safearea": {
|
||||
"bottom": {
|
||||
"offset": "none"
|
||||
}
|
||||
"bottom": {
|
||||
"offset": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,14 +679,14 @@
|
||||
"animationType": "slide-in-bottom",
|
||||
"scrollIndicator": "none",
|
||||
"safearea": {
|
||||
"bottom": {
|
||||
"offset": "none"
|
||||
}
|
||||
"bottom": {
|
||||
"offset": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "entry/seller/index",
|
||||
"style": {
|
||||
@@ -740,7 +742,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
{
|
||||
"path": "live/room",
|
||||
@@ -802,7 +804,7 @@
|
||||
"path": "point/pointList",
|
||||
"style": {
|
||||
"navigationBarTitleText": "积分商城"
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -818,7 +820,7 @@
|
||||
"navigationBarTitleText": "我的优惠券",
|
||||
"app-plus": {
|
||||
"bounce": "coupon/none"
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -834,13 +836,13 @@
|
||||
"navigationBarTitleText": "优惠券"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "coupon/couponCenter",
|
||||
"style": {
|
||||
"navigationBarTitleText": "领券中心",
|
||||
"enablePullDownRefresh": true
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -897,7 +899,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
]
|
||||
|
||||
},
|
||||
@@ -928,7 +930,7 @@
|
||||
"enablePullDownRefresh": true,
|
||||
"app-plus": {
|
||||
"bounce": "none"
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -969,7 +971,7 @@
|
||||
"navigationBarTitleText": "评价详情"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "evaluate/releaseEvaluate",
|
||||
"style": {
|
||||
@@ -982,7 +984,7 @@
|
||||
"navigationBarTitleText": "我的评价"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"path": "afterSales/applyProgress",
|
||||
"style": {
|
||||
@@ -1067,7 +1069,7 @@
|
||||
"selectedIconPath": "static/tabbar/category-s.png",
|
||||
"text": "分类"
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"pagePath": "pages/tabbar/cart/cartList",
|
||||
"iconPath": "static/tabbar/cart.png",
|
||||
@@ -1082,11 +1084,11 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
// #todo 为什么要注释condition下代码?
|
||||
// #todo 为什么要注释condition下代码?
|
||||
// IOS plus.runtime.arguments 添加 condition节点后, 框架会修改 runtime.arguments
|
||||
// 会影响什么功能?
|
||||
// -在h5中唤醒app会一直返回默认值 {"name":"","path":"","query":"","id":0}
|
||||
"condition": { //模式配置,仅开发期间生效
|
||||
"condition": { //模式配置,仅开发期间生效
|
||||
// "current": 0, //当前激活的模式(list 的索引项)
|
||||
// "list": [{
|
||||
// "name": "", //模式名称
|
||||
|
||||
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>
|
||||
@@ -1,14 +1,21 @@
|
||||
<template>
|
||||
<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="avatar-container">
|
||||
<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 class="user-detail-info">
|
||||
@@ -16,17 +23,19 @@
|
||||
<text class="user-nickname">{{ userInfo.id ? userInfo.nickName : '登录 / 注册' }}</text>
|
||||
</view>
|
||||
<view class="user-sub-row">
|
||||
<view class="badge-tag" v-if="userInfo.id">
|
||||
<text>普通会员</text>
|
||||
<view class="badge-tag" v-if="userInfo.id" @click.stop="navigateTo('/pages/mine/memberBenefits')">
|
||||
<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>
|
||||
<text class="user-sub-text" v-else>
|
||||
登录享受更多会员特权与折扣
|
||||
登录享受更多会员权益
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -115,7 +124,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
onLoad,
|
||||
onShow,
|
||||
@@ -130,6 +139,13 @@ import { useStore } from '@/store'
|
||||
import { getThemeStyle } from '@/utils/theme'
|
||||
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 themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||
const lightColor = computed(() => store.getters.lightColor)
|
||||
@@ -142,8 +158,74 @@ const couponNum = ref<string | number>('')
|
||||
const footNum = 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
|
||||
}
|
||||
|
||||
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()
|
||||
walletNum.value = 0
|
||||
couponNum.value = 0
|
||||
footNum.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// 动态计算顶部安全留白,避免顶到灵动岛/状态栏
|
||||
const headerPaddingTop = ref<number>(88)
|
||||
const headerPaddingTop = ref<number>(80)
|
||||
|
||||
function initSafeTop() {
|
||||
try {
|
||||
@@ -152,17 +234,17 @@ function initSafeTop() {
|
||||
// #ifdef MP-WEIXIN
|
||||
const menu = uni.getMenuButtonBoundingClientRect()
|
||||
if (menu && menu.bottom) {
|
||||
// 头像整行落到微信胶囊下方,预留 16px 呼吸空隙,彻底消除压迫感
|
||||
headerPaddingTop.value = menu.bottom + 16
|
||||
// 胶囊下方保留 8px,减少空白且不遮挡用户信息
|
||||
headerPaddingTop.value = menu.bottom + 8
|
||||
} else {
|
||||
headerPaddingTop.value = statusBar + 54
|
||||
headerPaddingTop.value = statusBar + 44
|
||||
}
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
headerPaddingTop.value = statusBar + 32
|
||||
headerPaddingTop.value = statusBar + 20
|
||||
// #endif
|
||||
} catch (e) {
|
||||
headerPaddingTop.value = 88
|
||||
headerPaddingTop.value = 80
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,28 +252,24 @@ onLoad(() => {
|
||||
initSafeTop()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
async function refreshUserPage() {
|
||||
userInfo.value = isLogin() || {}
|
||||
if (isLogin('auth')) {
|
||||
getUserOrderNum()
|
||||
} else {
|
||||
walletNum.value = 0
|
||||
couponNum.value = 0
|
||||
footNum.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
userInfo.value = isLogin() || {}
|
||||
if (isLogin('auth')) {
|
||||
getUserOrderNum()
|
||||
} else {
|
||||
walletNum.value = 0
|
||||
couponNum.value = 0
|
||||
footNum.value = 0
|
||||
try {
|
||||
if (isLogin('auth')) {
|
||||
await Promise.allSettled([loadMemberGrade(), getUserOrderNum()])
|
||||
} else {
|
||||
resetMemberGrade()
|
||||
walletNum.value = 0
|
||||
couponNum.value = 0
|
||||
footNum.value = 0
|
||||
}
|
||||
} finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onShow(refreshUserPage)
|
||||
onPullDownRefresh(refreshUserPage)
|
||||
|
||||
// #ifndef MP
|
||||
onNavigationBarButtonTap((e) => {
|
||||
@@ -212,6 +290,7 @@ function userDetail() {
|
||||
}
|
||||
|
||||
async function getUserOrderNum() {
|
||||
const memberId = String(userInfo.value.id || '')
|
||||
try {
|
||||
// Each request can fail independently; a missing wallet must not break the other counts.
|
||||
const results = await Promise.allSettled([
|
||||
@@ -220,15 +299,15 @@ async function getUserOrderNum() {
|
||||
getUserWallet(),
|
||||
])
|
||||
// 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]
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled' || !result.value?.data?.success) return
|
||||
const data = result.value.data.result
|
||||
targets[index].value = index === 2 ? (data?.memberWallet ?? 0) : (data ?? 0)
|
||||
})
|
||||
} finally {
|
||||
uni.stopPullDownRefresh()
|
||||
} catch {
|
||||
// 等级与资产请求独立,接口异常不影响页面其它内容。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -248,19 +327,35 @@ async function getUserOrderNum() {
|
||||
background: linear-gradient(175deg, #e53238 0%, #eb4448 45%, #ff5e62 100%);
|
||||
padding-left: 28rpx;
|
||||
padding-right: 28rpx;
|
||||
padding-bottom: 64rpx;
|
||||
padding-bottom: 44rpx;
|
||||
border-bottom-left-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;
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 6rpx;
|
||||
padding: 4rpx 6rpx;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
@@ -272,8 +367,8 @@ async function getUserOrderNum() {
|
||||
flex-shrink: 0;
|
||||
|
||||
.avatar-img {
|
||||
width: 124rpx;
|
||||
height: 124rpx;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.16);
|
||||
@@ -281,19 +376,6 @@ async function getUserOrderNum() {
|
||||
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 {
|
||||
@@ -306,9 +388,9 @@ async function getUserOrderNum() {
|
||||
align-items: center;
|
||||
|
||||
.user-nickname {
|
||||
font-size: 38rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
color: var(--member-header-color, #ffffff);
|
||||
letter-spacing: 0.5rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -317,25 +399,46 @@ async function getUserOrderNum() {
|
||||
}
|
||||
|
||||
.user-sub-row {
|
||||
margin-top: 12rpx;
|
||||
margin-top: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
|
||||
.badge-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
max-width: 100%;
|
||||
min-height: 40rpx;
|
||||
box-sizing: border-box;
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
color: var(--member-header-color, #ffffff);
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
padding: 3rpx 14rpx;
|
||||
padding: 2rpx 12rpx 2rpx 6rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.35);
|
||||
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 {
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: var(--member-header-color, #ffffff);
|
||||
opacity: 0.88;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -350,12 +453,13 @@ async function getUserOrderNum() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 核心资产栏(沉浸在红色背景中) */
|
||||
/* 核心资产栏 */
|
||||
.hero-assets {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 44rpx;
|
||||
margin-top: 24rpx;
|
||||
padding: 0 12rpx;
|
||||
|
||||
.asset-col {
|
||||
@@ -374,7 +478,7 @@ async function getUserOrderNum() {
|
||||
.asset-number-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: #ffffff;
|
||||
color: var(--member-header-color, #ffffff);
|
||||
|
||||
.symbol {
|
||||
font-size: 22rpx;
|
||||
@@ -384,7 +488,7 @@ async function getUserOrderNum() {
|
||||
}
|
||||
|
||||
.num {
|
||||
font-size: 40rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
@@ -404,9 +508,10 @@ async function getUserOrderNum() {
|
||||
}
|
||||
|
||||
.asset-title {
|
||||
margin-top: 10rpx;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: var(--member-header-color, #ffffff);
|
||||
opacity: 0.88;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
@@ -414,7 +519,8 @@ async function getUserOrderNum() {
|
||||
.asset-sep {
|
||||
width: 2rpx;
|
||||
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