2026-08-30-membership-center-admin-redesign.md 38 KB

会员中心重设计 + 用户管理会员功能扩展 实现计划

面向 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(用户搜索)
  • 前端 API 封装(已存在):
    • updateMembershipUser(data)cfc-web/src/api/admin.js:719
    • searchUsers(params)cfc-web/src/api/admin.js:1176
    • getUserList(params)cfc-web/src/api/admin.js(Users.vue)
  • 等级表 Tag 防御: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 新增「会员信息」按钮 + 弹窗 修改

任务 1:后端 —— 改会员等级自动送券

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

    • 注入区(第 63-79 行附近):新增 CouponService 注入
    • 导入区(第 11-12 行附近):新增 CouponService import
    • 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;
  • 步骤 2:在等级变化分支追加送券

修改 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());
                }
            }
  • 步骤 3:编译验证

运行:

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优惠券 - 与小程序购买一致(幂等防重)"
    

任务 2:重设计会员中心(MembershipCenter.vue)

文件:

  • 重写:cfc-web/src/views/admin/MembershipCenter.vue

重写整个文件为「统计卡 + 3 个 Tab 页签」结构。内容较长,拆为 template、script、style 三个子步骤,每个子步骤内替换对应区块。

步骤 2a:重写 template —— 统计卡 + Tab 容器 + Tab1 等级配置

  • 步骤 2a-1:替换整个 <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:重写 script

  • [ ] 步骤 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:重写 style

  • [ ] 步骤 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 通过为准。)

  • 步骤 2e:手工冒烟验证

启动 dev server(若未运行):

cd cfc-web && npm run serve -- --port 8083

打开 http://127.0.0.1:8083/admin/#/membership-center(或实际路由),确认:

  1. 顶部 6 张统计卡正常显示且可点击跳转对应 Tab
  2. 三个 Tab 可切换,等级配置/升级记录/会员维护各自正常渲染
  3. 会员维护弹窗:搜索用户→选择等级(下拉含 safeLevels)→填推荐人→保存→提示成功
  4. 无白屏、无 console 报错
  • [ ] 步骤 2f:Commit

    git add cfc-web/src/views/admin/MembershipCenter.vue
    git commit -m "feat(web): 会员中心重构为统计卡+Tab页签(等级配置/升级记录/会员维护) - 含safeLevels防御"
    

任务 3:用户管理增加「会员信息」(Users.vue)

文件:

  • 修改:cfc-web/src/views/Users.vue

步骤 3a:操作列新增「会员信息」按钮

  • 步骤 3a-1:修改操作列表格列(第 104-117 行)

在操作列中,在现有「编辑」按钮之后、「更多」下拉之前插入「会员信息」按钮,并将操作列宽度从 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:新增「会员信息」弹窗

  • [ ] 步骤 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>
    

步骤 3c:script 增加会员信息逻辑

  • 步骤 3c-1:修改 import(第 203 行)

将:

import { getUserList, updateUser, deleteUser, resetUserPassword } from '@/api/admin'

改为:

import { getUserList, updateUser, deleteUser, resetUserPassword, updateMembershipUser, listMembershipLevels } from '@/api/admin'
  • 步骤 3c-2:data 增加会员信息状态(第 215 行 data() 内)

在 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。

  • 步骤 3e:手工冒烟验证

打开 http://127.0.0.1:8083/admin/#/users(或实际路由):

  1. 操作列出现「会员信息」按钮
  2. 点击打开弹窗,昵称/手机号只读显示正确
  3. 等级下拉加载 5 个会员等级
  4. 修改等级+推荐人 → 保存 → 提示成功 → 列表刷新
  5. 无 console 报错
  • [ ] 步骤 3f:Commit

    git add cfc-web/src/views/Users.vue
    git commit -m "feat(web): 用户管理增加会员信息按钮 - 直接修改会员等级与推荐人"
    

任务 4:端到端联调验证(后端送券)

文件:(无文件修改,仅验证)

  • 步骤 4a:确认后端已运行或启动

后端运行中的 prod jar 是 /app/pro/cfc/backend/cfc-backend-1.0.0.jar注意:本次新增的送券代码需要重新打包并重新部署后端 jar 才能生效。 若开发环境用 mvn spring-boot:run,先启动开发后端,验证改动;生产部署另行处理。

  • 步骤 4b:调用接口验证送券(开发后端 localhost:9082)
  1. 登录获取 token:

    curl -s -X POST http://localhost:9082/api/admin-auth/login-by-password \
    -H 'Content-Type: application/json' \
    -d '{"phone":"13800138000","password":"admin123"}'
    
  2. 找一个测试用户(如目标 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;

  3. 验证该用户是否收到 JOIN 券(查询 user_coupon 表 + coupon_grant_log 表,需连接数据库或通过用户优惠券接口):

    • user_coupon 表:该用户 coupon_id 对应的 JOIN 券已新增,status='AVAILABLE'
    • coupon_grant_log 表:新增一条 grant_type='JOIN'source LIKE 'admin:%' 的记录
  4. 幂等验证:再次调用相同等级变更(或把等级从 FAMILY 改回 FREE 再改回 FAMILY),确认同一张 JOIN 券不会重复发放(唯一键 uk_grant 防重)。

  • 步骤 4c:生产部署(如需上线)

重新打包后端:

cd cfc-backend && mvn clean package -DskipTests

将新 jar 替换 /app/pro/cfc/backend/cfc-backend-1.0.0.jar,重启后端;将 cfc-web 构建产物 rsync 到部署目录。


自检

1. 规格覆盖度:

  • 会员中心重设计(统计卡 + 3 Tab)→ 任务 2 ✅
  • 用户管理「会员信息」按钮 + 弹窗 → 任务 3 ✅
  • 后台改等级自动送券 → 任务 1 + 任务 4 验证 ✅

2. 占位符扫描: 所有代码步骤均包含完整代码块,无 TODO/占位符。

3. 类型/方法一致性:

  • grantJoinCoupons(Long, String, String) 签名与 CouponService.java:209 一致 ✅
  • updateMembershipUser(data) 与 admin.js:719 一致 ✅
  • listMembershipLevels() 返回结构:调用 request(axios 拦截器解包后返回 res.data.datares.data),处理了两种形态 ✅
  • searchUsers(params) 与 admin.js:1176 一致 ✅
  • Users.vue 新方法名 handleMemberInfo / submitMemberInfo 与模板绑定一致 ✅
  • safeLevels/safeRecords 计算属性名与 template 绑定一致 ✅

注意点:

  • AdminController 无自定义 logger,送券异常用 System.err.println 记录,避免吞异常且不改动 logger 结构。若项目规范偏好 logger,可改为注入一个静态 Logger(本计划采用最小改动方案)。
  • 管理端前端无 jest 测试框架(AGENTS 仅规定后端 mvn clean compile 验证),故前端以 npm run build + 手工冒烟验证为准,符合项目现有验证方式。