面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 重设计会员中心为「统计卡 + 3 个 Tab 页签」,在用户管理增加「会员信息」修改入口,并让后台改会员等级时自动赠送 JOIN 优惠券(与小程序购买一致)。
架构: 前端 cfc-web Vue2 + Element UI 重构 MembershipCenter.vue 与 Users.vue;后端 AdminController.updateMembershipUser 注入 CouponService,等级实际变化后调用 grantJoinCoupons 送券(幂等)。不新增后端接口,全部复用现有 API。
技术栈: Vue 2 Options API + Element UI 2.15 / Spring Boot 2.7.18 + MyBatis-Plus + Java 8
规格文档:docs/superpowers/specs/2026-08-30-membership-center-admin-redesign.md
关键复用点:
CouponService.grantJoinCoupons(Long userId, String levelCode, String source)(幂等,period="JOIN" 唯一键防重)POST /api/admin/membership/user-update(改 memberLevel + referrerId)POST /api/admin/membership/levels(等级列表)POST /api/admin/membership/overview(统计)POST /api/admin/membership/upgrade-records(升级记录)POST /api/admin/user/search(用户搜索)updateMembershipUser(data) — cfc-web/src/api/admin.js:719searchUsers(params) — cfc-web/src/api/admin.js:1176getUserList(params) — cfc-web/src/api/admin.js(Users.vue)safeLevels 计算属性(过滤 null/undefined/无 levelCode 元素)+ v-if 空值保护(避免 el-dialog v-show 白屏)| 文件 | 职责 | 操作 |
|---|---|---|
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java |
注入 CouponService,改等级后送券 | 修改 |
cfc-web/src/views/admin/MembershipCenter.vue |
重设计:统计卡 + 3 Tab | 修改(重写 template + script + style) |
cfc-web/src/views/Users.vue |
新增「会员信息」按钮 + 弹窗 | 修改 |
文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
updateMembershipUser(第 1421-1433 行):等级变化分支追加送券[ ] 步骤 1:添加 CouponService import 与注入
在现有 import 区(第 11-12 行 import com.etotem.cfc.service.TaskService; / import com.etotem.cfc.service.UserService;)之后,追加:
import com.etotem.cfc.service.CouponService;
在注入区(现有 @Resource private MemberUpgradeRecordMapper memberUpgradeRecordMapper; 第 79 行之后)追加:
@Resource
private CouponService couponService;
修改 updateMembershipUser 中第 1421-1433 行的 if (newLevel != null && !oldLevel.equals(newLevel)) 块,在现有的 memberUpgradeRecordMapper.insert(record); 之后(第 1432 行后)追加送券逻辑:
if (newLevel != null && !oldLevel.equals(newLevel)) {
MemberUpgradeRecord record = new MemberUpgradeRecord();
record.setUserId(targetUserId);
record.setFromLevel(oldLevel);
record.setToLevel(newLevel);
record.setUpgradeType("admin");
record.setApproveUserId(adminUserId);
record.setApproveStatus("approved");
record.setApproveTime(new Date());
record.setApproveRemark("管理员会员维护");
record.setCreatedAt(new Date());
memberUpgradeRecordMapper.insert(record);
// 等级变化后,按新等级发放 JOIN 会员券(与小程序购买会员一致,幂等防重)
try {
couponService.grantJoinCoupons(targetUserId, newLevel, "admin:" + adminUserId);
} catch (Exception e) {
// 送券失败不影响等级更新,仅记录日志
System.err.println("[Membership] 后台改等级发放JOIN会员券异常: userId="
+ targetUserId + ", levelCode=" + newLevel + ", err=" + e.getMessage());
}
}
运行:
cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS,无编译错误。
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
git commit -m "feat(backend): 后台修改会员等级自动发放JOIN优惠券 - 与小程序购买一致(幂等防重)"
文件:
cfc-web/src/views/admin/MembershipCenter.vue重写整个文件为「统计卡 + 3 个 Tab 页签」结构。内容较长,拆为 template、script、style 三个子步骤,每个子步骤内替换对应区块。
<template>将原 <template>...</template>(第 1-262 行)整体替换为以下内容:
<template>
<div class="membership-center admin-page">
<!-- 统计卡片(点击跳转对应Tab) -->
<el-row :gutter="20" style="margin-bottom:20px;">
<el-col :span="4">
<el-card shadow="hover" class="stat-card" @click.native="jumpTab('levels')">
<div class="stat-value">{{ overview.freeCount }}</div>
<div class="stat-label">免费用户</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card stat-family" @click.native="jumpTab('records')">
<div class="stat-value">{{ overview.familyCount }}</div>
<div class="stat-label">FAMILY会员</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card stat-provider" @click.native="jumpTab('maintain')">
<div class="stat-value">{{ overview.providerCount }}</div>
<div class="stat-label">PROVIDER会员</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card stat-new" @click.native="jumpTab('records')">
<div class="stat-value">{{ overview.newFamilyThisMonth }}</div>
<div class="stat-label">本月新增</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card stat-lifetime" @click.native="jumpTab('records')">
<div class="stat-value">{{ overview.lifetimeCount }}</div>
<div class="stat-label">终身会员</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card stat-total">
<div class="stat-value">{{ overview.totalFamilyMemberships || 0 }}</div>
<div class="stat-label">会员总数</div>
</el-card>
</el-col>
</el-row>
<!-- Tab 页签 -->
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab1: 会员等级配置 -->
<el-tab-pane label="会员等级配置" name="levels">
<div class="toolbar">
<el-button type="primary" size="small" icon="el-icon-refresh" @click="loadLevels">刷新</el-button>
</div>
<el-table :max-height="tableHeight" :data="safeLevels" v-loading="levelsLoading" border stripe>
<el-table-column prop="levelCode" label="等级编码" width="120">
<template slot-scope="{ row }">
<el-tag v-if="row" :type="levelTagType(row.levelCode)" size="mini">{{ row.levelCode }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="levelName" label="等级名称" width="150" />
<el-table-column prop="levelDesc" label="描述" min-width="180" show-overflow-tooltip />
<el-table-column label="月费(元)" width="100">
<template slot-scope="{ row }">{{ row ? formatPrice(row.priceMonthly) : '—' }}</template>
</el-table-column>
<el-table-column label="季费(元)" width="100">
<template slot-scope="{ row }">{{ row ? formatPrice(row.priceQuarterly) : '—' }}</template>
</el-table-column>
<el-table-column label="年费(元)" width="100">
<template slot-scope="{ row }">{{ row ? formatPrice(row.priceYearly) : '—' }}</template>
</el-table-column>
<el-table-column prop="maxChildren" label="孩子上限" width="90" />
<el-table-column prop="maxTasksPerDay" label="每日任务" width="90" />
<el-table-column label="AI审核" width="80">
<template slot-scope="{ row }">
<el-tag v-if="row" :type="row.aiReviewEnabled ? 'success' : 'info'" size="mini">{{ row.aiReviewEnabled ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="优先客服" width="80">
<template slot-scope="{ row }">
<el-tag v-if="row" :type="row.prioritySupport ? 'success' : 'info'" size="mini">{{ row.prioritySupport ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template slot-scope="{ row }">
<el-button v-if="row" size="mini" type="primary" @click="handleEditLevel(row)">编辑</el-button>
<el-dropdown v-if="row" trigger="hover" @command="(cmd) => handleLevelCmd(row, cmd)">
<el-button size="mini">
更多<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="features" icon="el-icon-view">查看权限</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab2: 升级记录 -->
<el-tab-pane label="升级记录" name="records">
<div class="toolbar">
<el-select v-model="recordFilter.toLevel" placeholder="按到等级筛选" clearable size="small" style="width:180px" @change="handleRecordFilterChange">
<el-option v-for="lv in safeLevels" :key="'f' + lv.levelCode" :label="lv.levelName" :value="lv.levelCode" />
</el-select>
<el-button type="primary" size="small" icon="el-icon-refresh" @click="loadUpgradeRecords">刷新</el-button>
</div>
<el-table :data="safeRecords" v-loading="recordsLoading" border stripe :max-height="tableHeight">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="用户" width="120">
<template slot-scope="{ row }">{{ row ? (row.userName || '用户' + row.userId) : '—' }}</template>
</el-table-column>
<el-table-column label="从" width="100">
<template slot-scope="{ row }">
<el-tag v-if="row" size="mini">{{ row.fromLevel || 'FREE' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="到" width="100">
<template slot-scope="{ row }">
<el-tag v-if="row" type="success" size="mini">{{ row.toLevel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="upgradeType" label="升级方式" width="120">
<template slot-scope="{ row }">
<el-tag v-if="row" :type="upgradeTypeTag(row.upgradeType)" size="mini">{{ upgradeTypeLabel(row.upgradeType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="orderId" label="订单ID" width="80" />
<el-table-column prop="expireTime" label="到期时间" width="160">
<template slot-scope="{ row }">{{ row ? formatTime(row.expireTime) : '—' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160">
<template slot-scope="{ row }">{{ row ? formatTime(row.createdAt) : '—' }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="recordsTotal > 0"
@size-change="handleRecordSizeChange"
@current-change="handleRecordPageChange"
:current-page="recordFilter.page"
:page-sizes="[10, 20, 50]"
:page-size="recordFilter.size"
layout="total, sizes, prev, pager, next, jumper"
:total="recordsTotal"
style="margin-top:16px;text-align:right"
/>
</el-tab-pane>
<!-- Tab3: 会员维护 -->
<el-tab-pane label="会员维护" name="maintain">
<div class="toolbar">
<el-input
v-model="maintainSearch"
placeholder="输入手机号/昵称/用户ID搜索"
clearable
style="width:280px;margin-right:10px;"
@keyup.enter.native="handleMaintainSearch"
/>
<el-button type="primary" size="small" @click="handleMaintainSearch">搜索</el-button>
</div>
<div v-if="maintainUsers.length" style="margin-top:14px;">
<el-table :data="maintainUsers" border stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="nickname" label="昵称" width="140" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column label="当前等级" width="110">
<template slot-scope="{ row }">
<el-tag v-if="row" :type="levelTagType(row.memberLevel)" size="mini">{{ row.memberLevel || 'FREE' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="推荐人ID" width="100">
<template slot-scope="{ row }">{{ row ? (row.referrerId || '—') : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template slot-scope="{ row }">
<el-button v-if="row" size="mini" type="warning" @click="openMaintainDialog(row)">维护</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<!-- 会员维护对话框(v-if 懒渲染防白屏) -->
<el-dialog v-if="maintainVisible" title="会员维护" :visible.sync="maintainVisible" width="520px" :close-on-click-modal="false">
<el-form :model="maintainForm" label-width="100px" size="small">
<el-form-item label="用户">
<span>{{ maintainForm.nickname }}(ID: {{ maintainForm.id }})</span>
</el-form-item>
<el-form-item label="会员等级">
<el-select v-model="maintainForm.memberLevel" style="width:200px;" placeholder="选择等级">
<el-option
v-for="lv in safeLevels"
:key="lv.levelCode"
:label="lv.levelName + ' (' + lv.levelCode + ')'"
:value="lv.levelCode"
/>
</el-select>
<span style="color:#999;margin-left:8px;">选终身会员则永久有效</span>
</el-form-item>
<el-form-item label="推荐人ID">
<el-input v-model="maintainForm.referrerIdStr" placeholder="留空不变,填0清除" style="width:200px;" />
<span style="color:#999;margin-left:8px;">填目标用户ID</span>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="maintainVisible = false">取消</el-button>
<el-button type="primary" :loading="maintainSubmitting" @click="handleMaintainSubmit">保存</el-button>
</div>
</el-dialog>
<!-- 权限详情对话框(v-if 懒渲染防白屏) -->
<el-dialog v-if="featuresVisible" title="功能权限详情" :visible.sync="featuresVisible" width="500px">
<div v-if="currentLevel" class="features-content">
<h3>{{ currentLevel.levelName }} ({{ currentLevel.levelCode }})</h3>
<p style="color:#666;margin-bottom:16px;">{{ currentLevel.levelDesc }}</p>
<el-descriptions :column="1" border>
<el-descriptions-item label="孩子数量上限">{{ currentLevel.maxChildren }}</el-descriptions-item>
<el-descriptions-item label="每日任务上限">{{ currentLevel.maxTasksPerDay }}</el-descriptions-item>
<el-descriptions-item label="AI审核">{{ currentLevel.aiReviewEnabled ? '启用' : '未启用' }}</el-descriptions-item>
<el-descriptions-item label="优先客服">{{ currentLevel.prioritySupport ? '支持' : '不支持' }}</el-descriptions-item>
<el-descriptions-item label="老师咨询">{{ currentLevel.teacherConsultation ? '支持' : '不支持' }}</el-descriptions-item>
</el-descriptions>
<div v-if="currentLevel.features" style="margin-top:16px;">
<div class="features-title">功能权限列表</div>
<div class="features-list">
<el-tag v-for="(f, i) in parseFeatures(currentLevel.features)" :key="i" size="mini" style="margin:4px;">{{ f }}</el-tag>
<span v-if="!parseFeatures(currentLevel.features).length" style="color:#999;">暂无特殊权限</span>
</div>
</div>
</div>
</el-dialog>
<!-- 编辑会员等级对话框(提示去系统配置) -->
<el-dialog v-if="editVisible" title="编辑会员等级" :visible.sync="editVisible" width="540px">
<div style="padding:20px 0;text-align:center;color:#606266;">
<el-result icon="info" title="请在系统配置中编辑" sub-title="会员等级的名称、价格、权限请在系统配置页面维护"></el-result>
</div>
<div slot="footer">
<el-button @click="editVisible = false">关闭</el-button>
</div>
</el-dialog>
</div>
</template>
[ ] 步骤 2b-1:替换 <script> 内容(第 264-511 行)
<script>
import { searchUsers, updateMembershipUser } from '@/api/admin'
export default {
name: 'MembershipCenter',
data() {
return {
activeTab: 'levels',
// overview
overview: {
freeCount: 0,
familyCount: 0,
providerCount: 0,
newFamilyThisMonth: 0,
totalFamilyMemberships: 0,
lifetimeCount: 0
},
// levels
levelsLoading: false,
levels: [],
// upgrade records
recordsLoading: false,
upgradeRecords: [],
recordsTotal: 0,
recordFilter: {
page: 1,
size: 10,
toLevel: ''
},
// dialogs
featuresVisible: false,
currentLevel: null,
editVisible: false,
editForm: {},
// membership maintain
maintainSearch: '',
maintainUsers: [],
maintainVisible: false,
maintainSubmitting: false,
maintainForm: {
id: null,
nickname: '',
memberLevel: '',
referrerIdStr: ''
}
}
},
computed: {
tableHeight() {
return window.innerHeight - 340
},
safeLevels() {
const list = this.levels || []
return list.filter(function(l) { return l && typeof l === 'object' && l.levelCode })
},
safeRecords() {
const list = this.upgradeRecords || []
return list.filter(function(r) { return r && typeof r === 'object' })
}
},
created() {
this.loadOverview()
this.loadLevels()
this.loadUpgradeRecords()
},
methods: {
jumpTab(name) {
this.activeTab = name
},
formatPrice(price) {
if (price === null || price === undefined) return '—'
return (price / 100).toFixed(2)
},
formatTime(t) {
if (!t) return '-'
return t.replace ? t.replace('T', ' ').substring(0, 19) : t
},
levelTagType(code) {
if (code === 'FREE') return 'info'
if (code === 'FAMILY') return 'success'
if (code === 'PROVIDER') return 'warning'
if (code === 'LIFETIME') return 'danger'
return 'primary'
},
upgradeTypeLabel(type) {
const map = { pay: '付费升级', consumption: '消费升级', admin: '管理员操作', trial: '试用激活', lifetime: '终身会员授予' }
return map[type] || type || '—'
},
upgradeTypeTag(type) {
if (type === 'pay') return 'success'
if (type === 'consumption') return 'primary'
if (type === 'admin') return 'warning'
if (type === 'trial') return 'info'
if (type === 'lifetime') return 'danger'
return ''
},
parseFeatures(features) {
if (!features) return []
try {
if (features.startsWith('[')) return JSON.parse(features)
return features.split(',').map(f => f.trim()).filter(Boolean)
} catch (e) {
return []
}
},
async _request(url, data) {
const axios = this.$axios || (await import('axios')).default
const baseURL = process.env.VUE_APP_BASE_API || ''
return axios.post(baseURL + url, data)
},
async loadOverview() {
try {
const res = await this._request('/api/admin/membership/overview')
if (res.data && res.data.code === 200) {
Object.assign(this.overview, res.data.data)
}
} catch (e) {
// ignore
}
},
async loadLevels() {
this.levelsLoading = true
try {
const res = await this._request('/api/admin/membership/levels')
if (res.data && res.data.code === 200) {
const list = res.data.data || []
this.levels = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' }) : []
}
} catch (e) {
this.$message && this.$message.error && this.$message.error('加载等级配置失败')
} finally {
this.levelsLoading = false
}
},
async loadUpgradeRecords() {
this.recordsLoading = true
try {
const payload = {
page: this.recordFilter.page,
size: this.recordFilter.size
}
if (this.recordFilter.toLevel) payload.toLevel = this.recordFilter.toLevel
const res = await this._request('/api/admin/membership/upgrade-records', payload)
if (res.data && res.data.code === 200) {
const records = (res.data.data && res.data.data.records) || []
this.upgradeRecords = Array.isArray(records) ? records.filter(function(r) { return r && typeof r === 'object' }) : []
this.recordsTotal = res.data.data.total || 0
await this.resolveUserNames(this.upgradeRecords, 'userId')
}
} catch (e) {
this.$message && this.$message.error && this.$message.error('加载升级记录失败')
} finally {
this.recordsLoading = false
}
},
handleRecordFilterChange() {
this.recordFilter.page = 1
this.loadUpgradeRecords()
},
handleRecordSizeChange(size) {
this.recordFilter.size = size
this.recordFilter.page = 1
this.loadUpgradeRecords()
},
handleRecordPageChange(page) {
this.recordFilter.page = page
this.loadUpgradeRecords()
},
handleEditLevel(row) {
this.editForm = row || {}
this.editVisible = true
},
handleLevelCmd(row, cmd) {
switch (cmd) {
case 'features':
this.currentLevel = row
this.featuresVisible = true
break
}
},
resolveUserNames(items, idField) {
if (!items || items.length === 0) return Promise.resolve()
var ids = []
items.forEach(function(item) {
if (item[idField] && ids.indexOf(item[idField]) === -1) ids.push(item[idField])
})
var nameMap = {}
return Promise.all(ids.map(function(id) {
return searchUsers({ keyword: String(id) })
.then(function(res) {
if (res.data && res.data.length > 0) {
var u = res.data[0]
nameMap[id] = u.nickname || u.realName || '用户' + id
} else {
nameMap[id] = '用户' + id
}
})
.catch(function() {
nameMap[id] = '用户' + id
})
})).then(function() {
items.forEach(function(item) {
item.userName = nameMap[item[idField]] || '用户' + item[idField]
})
})
},
async handleMaintainSearch() {
const kw = (this.maintainSearch || '').trim()
if (!kw) {
this.$message && this.$message.warning && this.$message.warning('请输入搜索关键词')
return
}
try {
const res = await searchUsers({ keyword: kw })
if (res.data && res.data.length > 0) {
this.maintainUsers = res.data
} else {
this.maintainUsers = []
this.$message && this.$message.info && this.$message.info('未找到用户')
}
} catch (e) {
this.$message && this.$message.error && this.$message.error('搜索失败')
}
},
openMaintainDialog(row) {
this.maintainForm = {
id: row.id,
nickname: row.nickname || row.realName || ('用户' + row.id),
memberLevel: row.memberLevel || 'FREE',
referrerIdStr: row.referrerId ? String(row.referrerId) : ''
}
this.maintainVisible = true
},
async handleMaintainSubmit() {
if (!this.maintainForm.id) return
this.maintainSubmitting = true
try {
const payload = { userId: this.maintainForm.id }
if (this.maintainForm.memberLevel) {
payload.memberLevel = this.maintainForm.memberLevel
}
const refStr = (this.maintainForm.referrerIdStr || '').trim()
if (refStr === '0') {
payload.referrerId = ''
} else if (refStr !== '') {
payload.referrerId = refStr
}
const res = await updateMembershipUser(payload)
if (res.code === 200) {
this.$message && this.$message.success && this.$message.success(res.data || '更新成功')
this.maintainVisible = false
this.handleMaintainSearch()
this.loadOverview()
this.loadUpgradeRecords()
} else {
this.$message && this.$message.error && this.$message.error(res.message || '更新失败')
}
} catch (e) {
this.$message && this.$message.error && this.$message.error('更新失败')
} finally {
this.maintainSubmitting = false
}
}
}
}
</script>
[ ] 步骤 2c-1:替换 <style scoped> 内容(第 513-525 行)
<style scoped>
.membership-center { padding: 20px; }
.stat-card { text-align: center; cursor: pointer; }
.stat-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.15); }
.stat-card .stat-value { font-size: 32px; font-weight: bold; color: #303133; }
.stat-card .stat-label { font-size: 14px; color: #909399; margin-top: 8px; }
.stat-family .stat-value { color: #67C23A; }
.stat-provider .stat-value { color: #E6A23C; }
.stat-new .stat-value { color: #409EFF; }
.stat-lifetime .stat-value { color: #F56C6C; }
.stat-total .stat-value { color: #909399; }
.toolbar { margin-bottom: 14px; display: flex; align-items: center; gap: 10px; }
.features-content { padding: 10px 0; }
.features-title { font-weight: bold; margin-bottom: 8px; color: #606266; }
.features-list { display: flex; flex-wrap: wrap; }
</style>
[ ] 步骤 2d:构建验证前端
运行:
cd cfc-web && npm run build
预期:BUILD SUCCESS,无构建错误。(若 build 耗时过长,可运行 npx vue-cli-service lint --no-fix src/views/admin/MembershipCenter.vue 做语法检查替代,但以 build 通过为准。)
启动 dev server(若未运行):
cd cfc-web && npm run serve -- --port 8083
打开 http://127.0.0.1:8083/admin/#/membership-center(或实际路由),确认:
[ ] 步骤 2f:Commit
git add cfc-web/src/views/admin/MembershipCenter.vue
git commit -m "feat(web): 会员中心重构为统计卡+Tab页签(等级配置/升级记录/会员维护) - 含safeLevels防御"
文件:
cfc-web/src/views/Users.vue在操作列中,在现有「编辑」按钮之后、「更多」下拉之前插入「会员信息」按钮,并将操作列宽度从 200 调整为 260:
<el-table-column label="操作" width="260" fixed="right">
<template slot-scope="{ row }">
<el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button size="mini" type="warning" @click="handleMemberInfo(row)">会员信息</el-button>
<el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
<el-button size="mini">
更多<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="resetPassword" icon="el-icon-key">重置密码</el-dropdown-item>
<el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-table-column>
[ ] 步骤 3b-1:在编辑用户弹窗之后、重置密码弹窗之前插入会员信息弹窗(第 167 行之后)
<!-- 会员信息弹窗 -->
<el-dialog
title="会员信息"
:visible.sync="memberDialogVisible"
width="520px"
:close-on-click-modal="false"
>
<el-form :model="memberForm" label-width="100px" size="small">
<el-form-item label="用户昵称">
<span>{{ memberForm.nickname }}</span>
</el-form-item>
<el-form-item label="手机号">
<span>{{ memberForm.phone }}</span>
</el-form-item>
<el-form-item label="会员等级">
<el-select v-model="memberForm.memberLevel" placeholder="选择等级" style="width:220px">
<el-option
v-for="lv in memberLevels"
:key="lv.levelCode"
:label="lv.levelName + ' (' + lv.levelCode + ')'"
:value="lv.levelCode"
/>
</el-select>
<span style="color:#999;margin-left:8px;">选终身会员则永久有效</span>
</el-form-item>
<el-form-item label="推荐人ID">
<el-input v-model="memberForm.referrerIdStr" placeholder="留空不变,填0清除" style="width:220px" />
<span style="color:#999;margin-left:8px;">填目标用户ID</span>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="memberDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="memberSubmitting" @click="submitMemberInfo">保存</el-button>
</div>
</el-dialog>
将:
import { getUserList, updateUser, deleteUser, resetUserPassword } from '@/api/admin'
改为:
import { getUserList, updateUser, deleteUser, resetUserPassword, updateMembershipUser, listMembershipLevels } from '@/api/admin'
在 data 中(现有 editDialogVisible: false, 附近)增加:
memberDialogVisible: false,
memberSubmitting: false,
memberLevels: [],
memberForm: {
id: null,
nickname: '',
phone: '',
memberLevel: 'FREE',
referrerIdStr: ''
},
[ ] 步骤 3c-3:methods 增加会员信息方法(在 submitEdit 方法之后、handleDelete 之前)
async handleMemberInfo(row) {
this.memberForm = {
id: row.id,
nickname: row.nickname || '用户' + row.id,
phone: row.phone || '—',
memberLevel: row.memberLevel || 'FREE',
referrerIdStr: row.referrerId ? String(row.referrerId) : ''
}
this.memberDialogVisible = true
if (!this.memberLevels.length) {
try {
const res = await listMembershipLevels()
const list = (res.data && res.data.data) || (res.data || [])
this.memberLevels = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' && l.levelCode }) : []
} catch (e) {
this.$message.error('加载会员等级失败')
}
}
},
async submitMemberInfo() {
if (!this.memberForm.id) return
this.memberSubmitting = true
try {
const payload = { userId: this.memberForm.id }
if (this.memberForm.memberLevel) {
payload.memberLevel = this.memberForm.memberLevel
}
const refStr = (this.memberForm.referrerIdStr || '').trim()
if (refStr === '0') {
payload.referrerId = ''
} else if (refStr !== '') {
payload.referrerId = refStr
}
const res = await updateMembershipUser(payload)
if (res.code === 200) {
this.$message.success(res.data || '更新成功')
this.memberDialogVisible = false
this.loadUsers()
} else {
this.$message.error(res.message || '更新失败')
}
} catch (error) {
this.$message.error(error.message || '更新失败')
} finally {
this.memberSubmitting = false
}
},
[ ] 步骤 3d:构建验证前端
运行:
cd cfc-web && npm run build
预期:BUILD SUCCESS。
打开 http://127.0.0.1:8083/admin/#/users(或实际路由):
[ ] 步骤 3f:Commit
git add cfc-web/src/views/Users.vue
git commit -m "feat(web): 用户管理增加会员信息按钮 - 直接修改会员等级与推荐人"
文件:(无文件修改,仅验证)
后端运行中的 prod jar 是 /app/pro/cfc/backend/cfc-backend-1.0.0.jar。注意:本次新增的送券代码需要重新打包并重新部署后端 jar 才能生效。 若开发环境用 mvn spring-boot:run,先启动开发后端,验证改动;生产部署另行处理。
登录获取 token:
curl -s -X POST http://localhost:9082/api/admin-auth/login-by-password \
-H 'Content-Type: application/json' \
-d '{"phone":"13800138000","password":"admin123"}'
找一个测试用户(如目标 userId=1001),调用改等级接口(把等级从 FREE 改为 FAMILY):
curl -s -X POST http://localhost:9082/api/admin/membership/user-update \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer <token>" \
-d '{"userId":1001,"memberLevel":"FAMILY"}'
预期:返回 更新成功: 等级=FREE->FAMILY;
验证该用户是否收到 JOIN 券(查询 user_coupon 表 + coupon_grant_log 表,需连接数据库或通过用户优惠券接口):
user_coupon 表:该用户 coupon_id 对应的 JOIN 券已新增,status='AVAILABLE'coupon_grant_log 表:新增一条 grant_type='JOIN'、source LIKE 'admin:%' 的记录幂等验证:再次调用相同等级变更(或把等级从 FAMILY 改回 FREE 再改回 FAMILY),确认同一张 JOIN 券不会重复发放(唯一键 uk_grant 防重)。
重新打包后端:
cd cfc-backend && mvn clean package -DskipTests
将新 jar 替换 /app/pro/cfc/backend/cfc-backend-1.0.0.jar,重启后端;将 cfc-web 构建产物 rsync 到部署目录。
1. 规格覆盖度:
2. 占位符扫描: 所有代码步骤均包含完整代码块,无 TODO/占位符。
3. 类型/方法一致性:
grantJoinCoupons(Long, String, String) 签名与 CouponService.java:209 一致 ✅updateMembershipUser(data) 与 admin.js:719 一致 ✅listMembershipLevels() 返回结构:调用 request(axios 拦截器解包后返回 res.data.data 或 res.data),处理了两种形态 ✅searchUsers(params) 与 admin.js:1176 一致 ✅handleMemberInfo / submitMemberInfo 与模板绑定一致 ✅safeLevels/safeRecords 计算属性名与 template 绑定一致 ✅注意点:
System.err.println 记录,避免吞异常且不改动 logger 结构。若项目规范偏好 logger,可改为注入一个静态 Logger(本计划采用最小改动方案)。mvn clean compile 验证),故前端以 npm run build + 手工冒烟验证为准,符合项目现有验证方式。