feat(member): 完善移动端等级权益与经验任务

This commit is contained in:
pikachu1995@126.com
2026-11-05 21:29:56 +08:00
parent 8188a8af7b
commit 06af86af3f
6 changed files with 652 additions and 105 deletions

View File

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

View File

@@ -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": "", //模式名称

View File

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

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

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

View File

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

View File

@@ -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;
}
}