|
@@ -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` + 手工冒烟验证为准,符合项目现有验证方式。
|