Просмотр исходного кода

docs: 会员中心重设计+用户管理会员功能扩展 设计文档与实现计划

Xiaogang Liao 2 месяцев назад
Родитель
Сommit
501724d731

+ 931 - 0
docs/superpowers/plans/2026-08-30-membership-center-admin-redesign.md

@@ -0,0 +1,931 @@
+# 会员中心重设计 + 用户管理会员功能扩展 实现计划
+
+> **面向 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;`)之后,追加:
+
+```java
+import com.etotem.cfc.service.CouponService;
+```
+
+在注入区(现有 `@Resource private MemberUpgradeRecordMapper memberUpgradeRecordMapper;` 第 79 行之后)追加:
+
+```java
+    @Resource
+    private CouponService couponService;
+```
+
+- [ ] **步骤 2:在等级变化分支追加送券**
+
+修改 `updateMembershipUser` 中第 1421-1433 行的 `if (newLevel != null && !oldLevel.equals(newLevel))` 块,在现有的 `memberUpgradeRecordMapper.insert(record);` 之后(第 1432 行后)追加送券逻辑:
+
+```java
+            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:编译验证**
+
+运行:
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS,无编译错误。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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 行)整体替换为以下内容:
+
+```html
+<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 行)**
+
+```html
+<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 行)**
+
+```html
+<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:构建验证前端**
+
+运行:
+```bash
+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(若未运行):
+```bash
+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**
+
+```bash
+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:
+
+```html
+        <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 行之后)**
+
+```html
+    <!-- 会员信息弹窗 -->
+    <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 行)**
+
+将:
+```js
+import { getUserList, updateUser, deleteUser, resetUserPassword } from '@/api/admin'
+```
+改为:
+```js
+import { getUserList, updateUser, deleteUser, resetUserPassword, updateMembershipUser, listMembershipLevels } from '@/api/admin'
+```
+
+- [ ] **步骤 3c-2:data 增加会员信息状态(第 215 行 data() 内)**
+
+在 data 中(现有 `editDialogVisible: false,` 附近)增加:
+
+```js
+      memberDialogVisible: false,
+      memberSubmitting: false,
+      memberLevels: [],
+      memberForm: {
+        id: null,
+        nickname: '',
+        phone: '',
+        memberLevel: 'FREE',
+        referrerIdStr: ''
+      },
+```
+
+- [ ] **步骤 3c-3:methods 增加会员信息方法(在 submitEdit 方法之后、handleDelete 之前)**
+
+```js
+    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:构建验证前端**
+
+运行:
+```bash
+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**
+
+```bash
+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:
+```bash
+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):
+```bash
+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:生产部署(如需上线)**
+
+重新打包后端:
+```bash
+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.data` 或 `res.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` + 手工冒烟验证为准,符合项目现有验证方式。

+ 131 - 0
docs/superpowers/specs/2026-08-30-membership-center-admin-redesign.md

@@ -0,0 +1,131 @@
+# 会员中心重设计 + 用户管理会员功能扩展 设计文档
+
+日期:2026-08-30
+分支:cfclub
+
+## 背景与目标
+
+### 现状问题
+1. `MembershipCenter.vue`(525行)功能混杂:统计卡片 + 等级配置表 + 升级记录表 + 会员维护卡片挤在一个长页面,体验差。
+2. `Users.vue`(436行)操作列只有「编辑」「更多」,无法直接修改用户会员等级/推荐人,需跳转会员中心。
+3. 后台改会员等级**不送优惠券**,而小程序端用户购买会员会送 JOIN 赠券——行为不一致。
+
+### 本次目标
+1. **重设计会员中心**:统计卡 + 3 个 Tab 页签(等级配置 / 升级记录 / 会员维护),职责清晰、界面专业。
+2. **用户管理增加会员功能**:操作列新增「会员信息」按钮,弹窗改会员等级 + 推荐人。
+3. **后台改等级自动送券**:复用 `CouponService.grantJoinCoupons`,与小程序购买一致。
+
+## 涉及文件
+
+| 文件 | 改动 |
+|------|------|
+| `cfc-web/src/views/admin/MembershipCenter.vue` | 重构:统计卡 + Tab 页签 |
+| `cfc-web/src/views/Users.vue` | 新增「会员信息」按钮 + 弹窗 |
+| `cfc-backend/.../controller/admin/AdminController.java` | 注入 CouponService,改等级后送券 |
+| `cfc-web/src/api/admin.js` | 无需新增 API(复用现有) |
+
+## 后端接口(已有,复用)
+
+- `POST /api/admin/membership/levels` — 等级列表(5个:FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME)
+- `POST /api/admin/membership/overview` — 统计
+- `POST /api/admin/membership/upgrade-records` — 升级记录(分页)
+- `POST /api/admin/membership/user-update` — 改 memberLevel + referrerId
+- `POST /api/admin/user/search` — 用户搜索
+
+## 一、会员中心重设计(MembershipCenter.vue)
+
+### 页面结构:统计卡 + 3 个 Tab 页签
+
+```
+┌──────────────────────────────────────────────┐
+│  统计卡 ×5(带图标+渐变底色)                  │
+│  免费用户 / FAMILY会员 / PROVIDER会员 /         │
+│  本月新增 / 终身会员                           │
+├──────────────────────────────────────────────┤
+│  【会员等级配置】  【升级记录】  【会员维护】     │
+├──────────────────────────────────────────────┤
+│  Tab 内容区                                    │
+└──────────────────────────────────────────────┘
+```
+
+### Tab1:会员等级配置
+- 等级表格:id / levelCode / levelName / 描述 / 价格 / 孩子上限 / 每日任务 / AI审核 / 优先客服 / 操作
+- 操作列:**编辑**(编辑弹窗)+ **更多**(权限详情弹窗)
+- 编辑弹窗:输入名称/描述/价格/各权限开关,保存刷新
+- 权限详情弹窗:展示 features 权限说明
+
+### Tab2:升级记录
+- 顶部可选筛选:按「到等级」筛选(全部/FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME)
+- 升级记录表:ID / 用户 / 从等级 / 到等级 / 升级方式 / 订单ID / 到期时间 / 创建时间
+- 分页(默认10条)
+
+### Tab3:会员维护(核心操作)
+- 搜索区:手机号/昵称/用户ID + 搜索按钮
+- 结果表:ID / 昵称 / 手机号 / 当前等级(Tag) / 推荐人ID / 到期时间 / 操作
+- 操作列:**修改会员信息**
+- 修改弹窗:
+  - 用户信息只读(昵称/手机号/当前等级)
+  - 会员等级下拉(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME)
+  - 推荐人ID 输入框(留空不变/填0清除/填ID设定)
+  - 保存 → 刷新表格+统计
+
+### 交互设计
+- 统计卡点击 → 跳对应 Tab 筛选状态
+- 弹窗用 `v-if` 懒渲染(避免空数据抛错,参考 `safeLevels` 防御)
+
+## 二、用户管理增加「会员信息」(Users.vue)
+
+### 操作列新增按钮
+```
+[编辑]  [会员信息]  [更多▼]
+```
+
+### 「会员信息」弹窗
+| 字段 | 类型 |
+|------|------|
+| 用户昵称 | 只读 |
+| 手机号 | 只读 |
+| 当前会员等级 | 下拉(带Tag区分默认值) |
+| 推荐人ID | 输入框(留空不变/填0清除/填ID设定) |
+| 到期时间 | 可选日期(非LIFETIME时显示) |
+
+- 保存调用 `/api/admin/membership/user-update`
+- 成功后刷新用户列表 + Toast
+
+## 三、后台改等级自动送券(后端)
+
+### 改动点:`AdminController.updateMembershipUser`
+1. 注入 `@Resource CouponService couponService;`(需 import `com.etotem.cfc.service.CouponService`)
+2. 在等级实际变化(`newLevel != null && !oldLevel.equals(newLevel)`)且写入升级记录后,追加送券:
+
+```java
+// 等级变化后,按新等级发放 JOIN 会员券(与小程序购买会员一致,幂等)
+try {
+    couponService.grantJoinCoupons(targetUserId, newLevel, "admin:" + adminUserId);
+} catch (Exception e) {
+    log.error("后台改等级发放JOIN会员券异常: userId={}, levelCode={}", targetUserId, newLevel, e);
+}
+```
+
+### 复用逻辑 `CouponService.grantJoinCoupons`
+- 查找 `grant_type=JOIN` 且 `grant_level_code=levelCode` 的 ACTIVE 券模板
+- 通过 `grant(userId, couponId, "JOIN", "JOIN", source)` 发放
+- **幂等**:`uk_grant (user_id, coupon_id, grant_type, period)` 唯一键防重 → 同一等级券不重复发放
+
+### 关键行为矩阵
+| 场景 | 是否送券 |
+|------|---------|
+| FREE → FAMILY | ✅ 送 FAMILY JOIN 券 |
+| FAMILY → LIFETIME | ✅ 送 LIFETIME JOIN 券(如有模板) |
+| 只改推荐人(等级不变) | ❌ 不送 |
+| 重复保存相同等级 | ❌ 不送(幂等) |
+
+### 注意事项
+- 送券失败仅打日志不影响等级更新(与 MembershipService 一致)
+- `upgradeType="admin"` 升级记录已存在,送券在其后执行
+- source 用 `"admin:" + adminUserId` 标识后台来源
+
+## 验证
+- 后端:`cd cfc-backend && mvn clean compile`(唯一验证方式)
+- 前端:`cd cfc-web && npm run serve -- --port 8083`
+- 手工验证:后台改一个用户等级 → 检查该用户是否收到对应 JOIN 券(member_upgrade_records 有记录 + user_coupon 新增)