feat(会员管理): 添加第三方账户绑定和会员等级功能

- 在登录API中新增获取第三方账户绑定列表、绑定和解绑功能
- 在会员API中新增获取当前会员等级、等级规则和等级列表的接口
- 在会员中心页面中添加第三方账户绑定的UI和逻辑
- 新增会员等级页面,展示当前等级、经验值和经验值记录
This commit is contained in:
pikachu1995@126.com
2026-05-13 18:53:33 +08:00
parent fe4e0ce75c
commit aa4bc81da4
22 changed files with 3262 additions and 386 deletions

View File

@@ -0,0 +1,431 @@
<template>
<div class="memberGrade">
<card _Title="我的等级"/>
<div class="summary">
<div class="gradeCard">
<div class="gradeLeft">
<img v-if="currentGrade.gradeImage" class="gradeIcon" :src="currentGrade.gradeImage" alt="等级图标" />
<div class="gradeInfo">
<div class="gradeNameRow">
<span class="gradeNameText">{{ currentGrade.gradeName || '暂无等级' }}</span>
<span v-if="currentGradeBenefits.length" class="benefitTagsInline">
<Tag v-for="b in currentGradeBenefits" :key="b.id" class="benefitTag" color="blue">{{ b.benefitName || '-' }}</Tag>
</span>
</div>
<div class="gradeExp">
当前经验值<span class="num">{{ currentExperience || 0 }}</span>
</div>
<div class="gradeNext" v-if="nextGrade">
{{ nextGrade.gradeName }} 还差 <span class="num">{{ diffExperience }}</span> 经验值
</div>
<div class="gradeNext" v-else>已达最高等级</div>
</div>
</div>
</div>
</div>
<Tabs value="rules">
<TabPane label="获取经验值方式" name="rules">
<Table :columns="ruleColumns" :data="ruleList" :loading="rulesLoading">
<template slot-scope="{ row }" slot="ruleItem">
<div class="ruleItem">
<div class="ruleTop">
<div class="ruleName">
<span>{{ row.ruleName || row.ruleKey || '-' }}</span>
</div>
<Tag v-if="row.completed === true" color="success">已完成</Tag>
<Tag v-else color="default">未完成</Tag>
</div>
<div class="ruleDesc">{{ getRuleDesc(row.ruleKey) }}</div>
<div class="ruleMeta">
<span class="metaItem">单次获得<b>+{{ row.value == null ? 0 : row.value }}</b></span>
<span v-if="row.maxValue != null && Number(row.maxValue) > 0" class="metaItem">限额<b>{{ row.maxValue }}</b></span>
<span class="metaItem">已获得<b>{{ row.gainedExperience == null ? 0 : row.gainedExperience }}</b></span>
</div>
</div>
</template>
</Table>
<div class="rulesDesc" v-if="rulesDescription">{{ rulesDescription }}</div>
</TabPane>
<TabPane label="经验值记录" name="logs">
<Table :columns="logColumns" :data="experienceLogs.records || []" :loading="logLoading">
<template slot-scope="{ row }" slot="rule">
<span>{{ getRuleName(row.ruleKey) }}</span>
</template>
<template slot-scope="{ row }" slot="delta">
<div :style="{color: getLogDeltaColor(row)}">
<span v-if="getLogDeltaSign(row) === '+'">+</span>{{ getLogDeltaAbsValue(row) }}
</div>
</template>
</Table>
<Page
style="float:right;margin-top:10px"
:current="params.pageNumber"
:total="experienceLogs.total || 0"
:page-size="params.pageSize"
@on-change="changePage"
@on-page-size-change="changePageSize"
:page-size-opts="[10, 20, 50]"
size="small"
show-total
show-elevator
></Page>
</TabPane>
</Tabs>
</div>
</template>
<script>
import { memberGradeCurrent, memberGradeList, memberGradeRules } from '@/api/member.js'
export default {
name: 'MemberGrade',
data () {
return {
ruleList: [],
rulesLoading: false,
rulesDescription: '',
/** 接口返回 List MemberGradeDetailVO每项 { grade, benefits } */
gradeDetails: [],
gradeList: [],
currentGrade: {},
currentExperience: 0,
currentGradeId: '',
selectedRuleKey: '',
logLoading: false,
gradeLoading: false,
params: {
pageNumber: 1,
pageSize: 10
},
experienceLogs: {},
ruleColumns: [
{
title: '获取方式',
slot: 'ruleItem',
minWidth: 720
}
],
logColumns: [
{
title: '时间',
key: 'createTime',
align: 'center',
minWidth: 160
},
{
title: '规则',
slot: 'rule',
align: 'center',
minWidth: 160
},
{
title: '变动值',
slot: 'delta',
align: 'center',
minWidth: 120
}
]
}
},
computed: {
rulesMap () {
const map = {}
this.ruleList.forEach(item => {
map[item.ruleKey] = item.ruleName
})
return map
},
sortedGradeList () {
const list = Array.isArray(this.gradeList) ? this.gradeList.slice() : []
list.sort((a, b) => {
const sa = a && a.gradeSort != null ? Number(a.gradeSort) : 0
const sb = b && b.gradeSort != null ? Number(b.gradeSort) : 0
if (sa !== sb) return sa - sb
const ea = a && a.requiredExperience != null ? Number(a.requiredExperience) : 0
const eb = b && b.requiredExperience != null ? Number(b.requiredExperience) : 0
return ea - eb
})
return list
},
currentGradeIndex () {
if (!this.currentGradeId) return -1
return this.sortedGradeList.findIndex(g => g && g.id === this.currentGradeId)
},
nextGrade () {
const idx = this.currentGradeIndex
if (idx < 0) return null
return this.sortedGradeList[idx + 1] || null
},
diffExperience () {
if (!this.nextGrade || this.nextGrade.requiredExperience == null) return 0
const diff = Number(this.nextGrade.requiredExperience) - Number(this.currentExperience || 0)
return diff > 0 ? diff : 0
},
/** 当前等级在列表详情中对应的启用权益(顺序与后台 benefits 一致) */
currentGradeBenefits () {
const id = this.currentGradeId
if (!id || !Array.isArray(this.gradeDetails) || !this.gradeDetails.length) return []
const detail = this.gradeDetails.find(
(d) => d && d.grade && String(d.grade.id) === String(id)
)
const list = detail && Array.isArray(detail.benefits) ? detail.benefits : []
return list.filter((b) => b && b.benefitState === 'OPEN')
},
},
mounted () {
this.init()
},
methods: {
init () {
this.loadRules()
this.loadGradeList()
this.loadCurrent()
},
loadRules () {
this.rulesLoading = true
memberGradeRules()
.then(res => {
if (res && res.success) {
const items = res.result && Array.isArray(res.result.items) ? res.result.items : []
this.rulesDescription = (res.result && res.result.description) || ''
this.ruleList = items
}
})
.finally(() => {
this.rulesLoading = false
})
},
loadGradeList () {
this.gradeLoading = true
memberGradeList()
.then(res => {
if (res && res.success) {
const raw = Array.isArray(res.result) ? res.result : []
this.gradeDetails = raw
.map((item) => {
if (!item) return null
if (item.grade != null) {
return {
grade: item.grade,
benefits: Array.isArray(item.benefits) ? item.benefits : []
}
}
return { grade: item, benefits: [] }
})
.filter(Boolean)
this.gradeList = this.gradeDetails.map((d) => d.grade).filter(Boolean)
} else {
this.gradeDetails = []
this.gradeList = []
}
})
.finally(() => {
this.gradeLoading = false
})
},
loadCurrent () {
this.logLoading = true
const params = {
pageNumber: this.params.pageNumber,
pageSize: this.params.pageSize
}
if (this.selectedRuleKey) params.ruleKey = this.selectedRuleKey
memberGradeCurrent(params)
.then(res => {
if (res && res.success && res.result) {
this.currentGrade = res.result.grade || {}
this.currentExperience = res.result.experience || 0
this.currentGradeId = res.result.gradeId || (this.currentGrade && this.currentGrade.id) || ''
this.experienceLogs = res.result.experienceLogs || {}
}
})
.finally(() => {
this.logLoading = false
})
},
selectRule (ruleKey) {
this.selectedRuleKey = ruleKey || ''
this.params.pageNumber = 1
this.loadCurrent()
},
changePage (val) {
this.params.pageNumber = val
this.loadCurrent()
},
changePageSize (val) {
this.params.pageSize = val
this.params.pageNumber = 1
this.loadCurrent()
},
getRuleName (ruleKey) {
if (!ruleKey) return '-'
return this.rulesMap[ruleKey] || ruleKey
},
getRuleDesc (ruleKey) {
const map = {
CONSUME: '客户消费1元可获取经验值向下取整',
REGISTER: '会员注册成功后可获取经验值',
SIGN_IN: '客户每日签到后可获取的经验值',
COMMENT: '对已购买商品完成发表评价获取经验值仅针对评论字数大于30字的评论进行发放',
SHARE: '会员分享商城页面可获取的经验值',
PROFILE: '完善个人基本信息可获得经验值,每个会员仅可获得一次',
FOLLOW_STORE: '关注店铺可获得经验值每个客户ID同店铺仅第一次关注可进行获得',
BIND_WECHAT: '绑定微信成功后获得经验值,每个会员仅可获得一次',
ADD_ADDRESS: '添加收货地址可获得经验值,每个会员仅可获得一次',
SHARE_REGISTER: '仅被注册成功后可获得相应奖励经验值',
SHARE_BUY: '仅被购买成功后可获得相应奖励经验值'
}
if (!ruleKey) return '-'
return map[ruleKey] || '-'
},
getLogDeltaRaw (row) {
if (!row) return 0
const candidate = row.variableExperience != null ? row.variableExperience : (row.changeExperience != null ? row.changeExperience : (row.experience != null ? row.experience : row.value))
if (candidate == null) return 0
const num = Number(candidate)
if (Number.isNaN(num)) return 0
if (row.type === 'DECREASE') return -Math.abs(num)
if (row.type === 'INCREASE') return Math.abs(num)
return num
},
getLogDeltaSign (row) {
const num = this.getLogDeltaRaw(row)
return num >= 0 ? '+' : '-'
},
getLogDeltaAbsValue (row) {
const num = this.getLogDeltaRaw(row)
return Math.abs(num)
},
getLogDeltaColor (row) {
return this.getLogDeltaRaw(row) >= 0 ? 'green' : 'red'
}
}
}
</script>
<style scoped lang="scss">
h3 {
font-size: 16px;
margin: 20px 10px;
}
.summary {
margin-bottom: 20px;
}
.rulesDesc {
margin: 10px 0 0 0;
padding: 10px 12px;
border: 1px solid $border_color;
border-radius: 4px;
background: #fff;
color: $light_sub_color;
font-size: 12px;
line-height: 1.7;
}
.gradeCard {
padding: 16px;
border: 1px solid $border_color;
border-radius: 6px;
background: #fff;
background-repeat: no-repeat;
background-size: cover;
}
.gradeLeft {
display: flex;
align-items: flex-start;
gap: 12px;
}
.gradeIcon {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 4px;
background: #fff;
}
.gradeNameRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
line-height: 1.4;
}
.gradeNameText {
font-size: 16px;
color: $light_title_color;
font-weight: 600;
}
.benefitTagsInline {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
vertical-align: middle;
}
.benefitTag {
margin: 0 !important;
}
.gradeExp,
.gradeNext {
margin-top: 6px;
font-size: 12px;
color: #000;
}
.num {
color: $theme_color;
font-size: 16px;
}
.ruleItem {
padding: 10px 8px;
}
.ruleTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ruleName {
display: flex;
align-items: center;
gap: 8px;
color: $light_title_color;
font-size: 14px;
}
.ruleDesc {
margin-top: 6px;
color: $light_sub_color;
font-size: 12px;
line-height: 1.6;
}
.ruleMeta {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 16px;
color: $light_sub_color;
font-size: 12px;
}
.metaItem b {
color: $theme_color;
font-weight: 600;
}
</style>