Răsfoiți Sursa

feat(admin+web): 后台会员维护功能 - 修改会员等级/推荐人 + 修复会员中心打不开

- AdminController 新增 POST /api/admin/membership/user-update:
  按 userId 修改 memberLevel(含 LIFETIME 置空到期时间)与 referrerId,
  等级变化写入 MemberUpgradeRecord(upgradeType=admin)
- 用 LambdaUpdateWrapper 显式 SET,修复 referrerId 清除(null)不生效的 bug
- MembershipCenter.vue 新增「会员维护」卡片:搜索用户 → 修改等级/推荐人
- 修复会员中心打不开:loadLevels/loadUpgradeRecords 过滤 null 元素,
  所有表格 scoped slot 加空值保护(row 为 null 时不再抛 TypeError)
Xiaogang Liao 3 săptămâni în urmă
părinte
comite
d99ad81206

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller.admin;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
@@ -1339,6 +1340,104 @@ public class AdminController {
         return Result.success();
     }
 
+    /**
+     * 会员维护:按用户ID修改会员等级和推荐人
+     * body: { userId, memberLevel?, referrerId? }
+     */
+    @Operation(summary = "会员维护:修改会员等级与推荐人")
+    @PostMapping("/membership/user-update")
+    public Result<String> updateMembershipUser(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("userId") Long adminUserId,
+                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        if (params.get("userId") == null) {
+            return Result.error("参数缺失: userId");
+        }
+        Long targetUserId = ((Number) params.get("userId")).longValue();
+        User user = userMapper.selectById(targetUserId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+
+        StringBuilder changes = new StringBuilder();
+        String oldLevel = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
+        String newLevel = null;
+        LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(User::getId, targetUserId);
+
+        if (params.get("referrerId") != null) {
+            String raw = String.valueOf(params.get("referrerId"));
+            if (raw.trim().isEmpty()) {
+                if (user.getReferrerId() != null) {
+                    wrapper.set(User::getReferrerId, null);
+                    changes.append("清除推荐人; ");
+                }
+            } else {
+                try {
+                    Long referrerId = Long.parseLong(raw.trim());
+                    if (referrerId.equals(user.getId())) {
+                        return Result.error("推荐人不能是用户本人");
+                    }
+                    wrapper.set(User::getReferrerId, referrerId);
+                    changes.append("推荐人=").append(referrerId).append("; ");
+                } catch (NumberFormatException e) {
+                    return Result.error("推荐人ID格式不正确");
+                }
+            }
+        }
+
+        if (params.get("memberLevel") != null) {
+            String level = String.valueOf(params.get("memberLevel")).trim().toUpperCase();
+            if (level.isEmpty()) {
+                return Result.error("memberLevel 不能为空");
+            }
+            java.util.List<MembershipLevel> levels = membershipLevelMapper.selectList(null);
+            boolean valid = false;
+            for (MembershipLevel l : levels) {
+                if (l.getLevelCode() != null && l.getLevelCode().equalsIgnoreCase(level)) {
+                    valid = true;
+                    break;
+                }
+            }
+            if (!valid) {
+                return Result.error("无效的会员等级: " + level);
+            }
+            newLevel = level;
+            wrapper.set(User::getMemberLevel, newLevel);
+            if ("LIFETIME".equals(newLevel)) {
+                wrapper.set(User::getMemberExpireTime, null);
+            }
+            if (!oldLevel.equals(newLevel)) {
+                changes.append("等级=").append(oldLevel).append("->").append(newLevel).append("; ");
+            }
+        }
+
+        if (changes.length() > 0) {
+            wrapper.set(User::getUpdatedAt, new Date());
+            userMapper.update(null, wrapper);
+
+            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);
+            }
+        }
+
+        return Result.success(changes.length() > 0
+                ? "更新成功: " + changes.toString()
+                : "未做任何修改");
+    }
+
     // ========== 测评待分配规划师 ==========
 
     @PostMapping("/assessment/pending-list")

+ 8 - 0
cfc-web/src/api/admin.js

@@ -716,6 +716,14 @@ export function updateMembershipLevel(data) {
   })
 }
 
+export function updateMembershipUser(data) {
+  return request({
+    url: '/api/admin/membership/user-update',
+    method: 'post',
+    data
+  })
+}
+
 // ========== 分类管理 API ==========
 
 export function getCategoryTree(includeDisabled) {

+ 153 - 18
cfc-web/src/views/admin/MembershipCenter.vue

@@ -42,36 +42,36 @@
       <el-table :max-height="tableHeight" :data="levels" v-loading="levelsLoading" border stripe>
         <el-table-column prop="levelCode" label="等级编码" width="120">
           <template slot-scope="{ row }">
-            <el-tag :type="levelTagType(row.levelCode)" size="mini">{{ row.levelCode }}</el-tag>
+            <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 }">{{ formatPrice(row.priceMonthly) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatPrice(row.priceMonthly) : '—' }}</template>
         </el-table-column>
         <el-table-column label="季费(元)" width="100">
-          <template slot-scope="{ row }">{{ formatPrice(row.priceQuarterly) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatPrice(row.priceQuarterly) : '—' }}</template>
         </el-table-column>
         <el-table-column label="年费(元)" width="100">
-          <template slot-scope="{ row }">{{ formatPrice(row.priceYearly) }}</template>
+          <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 :type="row.aiReviewEnabled ? 'success' : 'info'" size="mini">{{ row.aiReviewEnabled ? '是' : '否' }}</el-tag>
+            <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 :type="row.prioritySupport ? 'success' : 'info'" size="mini">{{ row.prioritySupport ? '是' : '否' }}</el-tag>
+            <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 size="mini" type="primary" @click="handleEditLevel(row)">编辑</el-button>
-            <el-dropdown trigger="hover" @command="(cmd) => handleLevelCmd(row, cmd)">
+            <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>
@@ -92,29 +92,29 @@
     <el-table :data="upgradeRecords" 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.userName || '用户' + row.userId }}</template>
+          <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 size="mini">{{ row.fromLevel || 'FREE' }}</el-tag>
+            <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 type="success" size="mini">{{ row.toLevel }}</el-tag>
+            <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 :type="upgradeTypeTag(row.upgradeType)" size="mini">{{ upgradeTypeLabel(row.upgradeType) }}</el-tag>
+            <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 }">{{ formatTime(row.expireTime) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatTime(row.expireTime) : '—' }}</template>
         </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="160">
-          <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatTime(row.createdAt) : '—' }}</template>
         </el-table-column>
     </el-table>
     <el-pagination
@@ -130,6 +130,71 @@
       />
     </el-card>
 
+    <!-- 会员维护 -->
+    <el-card style="margin-top:20px;">
+      <div slot="header">
+        <span>会员维护</span>
+      </div>
+      <div class="maintain-row">
+        <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-card>
+
+    <!-- 会员维护对话框 -->
+    <el-dialog title="会员维护" :visible.sync="maintainVisible" width="520px" @close="maintainVisible = 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 levels"
+              :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>
+
     <!-- 权限详情对话框 -->
     <el-dialog title="功能权限详情" :visible.sync="featuresVisible" width="500px">
       <div v-if="currentLevel" class="features-content">
@@ -197,7 +262,7 @@
 </template>
 
 <script>
-import { searchUsers } from '@/api/admin'
+import { searchUsers, updateMembershipUser } from '@/api/admin'
 
 export default {
   name: 'MembershipCenter',
@@ -230,7 +295,18 @@ export default {
       },
       // dialogs
       featuresVisible: false,
-      currentLevel: null
+      currentLevel: null,
+      // membership maintain
+      maintainSearch: '',
+      maintainUsers: [],
+      maintainVisible: false,
+      maintainSubmitting: false,
+      maintainForm: {
+        id: null,
+        nickname: '',
+        memberLevel: '',
+        referrerIdStr: ''
+      }
     }
   },
   created() {
@@ -295,7 +371,8 @@ export default {
       try {
         const res = await this._request('/api/admin/membership/levels')
         if (res.data && res.data.code === 200) {
-          this.levels = res.data.data || []
+          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('加载等级配置失败')
@@ -311,7 +388,8 @@ export default {
           size: this.recordFilter.size
         })
 if (res.data && res.data.code === 200) {
-            this.upgradeRecords = res.data.data.records || []
+            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')
           }
@@ -366,6 +444,63 @@ if (res.data && res.data.code === 200) {
           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
+      }
     }
   }
 }