Kaynağa Gözat

feat(review): 添加营养师审核功能

后端: User实体添加nutritionistStatus/nutritionistRejectReason字段,
AdminController添加营养师审核3端点(applications/approve/reject),
ReviewCenterController增加nutritionistPending统计,
DatabaseInitializer迁移91,schema.sql同步
前端: NutritionistAudit.vue组件, ReviewCenter.vue添加营养师Tab,
admin.js添加营养师审核API函数
Xiaogang Liao 2 ay önce
ebeveyn
işleme
db06ab7601

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -5264,6 +5264,7 @@ try {
         runMigration88();
         runMigration89();
         runMigration90();
+        runMigration91();
     }
 
     private void runMigration81() {
@@ -5770,4 +5771,11 @@ try {
         ensureColumn("supply_system", "api_key", "VARCHAR(256) COMMENT 'API密钥'");
         log.info("已添加api_endpoint和api_key列到supply_system表");
     }
+
+    private void runMigration91() {
+        // 迁移 91: users表添加营养师审核字段
+        ensureColumn("users", "nutritionist_status", "VARCHAR(20) DEFAULT NULL COMMENT '营养师审核状态: pending/approved/rejected'");
+        ensureColumn("users", "nutritionist_reject_reason", "VARCHAR(500) DEFAULT NULL COMMENT '营养师审核拒绝原因'");
+        log.info("已添加nutritionist_status和nutritionist_reject_reason列到users表");
+    }
 }

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

@@ -678,6 +678,67 @@ public class AdminController {
         return Result.success(result);
     }
 
+    // ========== 营养师审核相关接口 ==========
+
+    @Operation(summary = "获取营养师审核列表")
+    @PostMapping("/nutritionist/applications")
+    public Result<Page<User>> getNutritionistApplications(@RequestBody Map<String, Object> params) {
+        String status = params.get("status") != null ? (String) params.get("status") : "pending";
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 10;
+
+        Page<User> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
+            .eq(User::getNutritionistStatus, status)
+            .isNotNull(User::getRealName)
+            .orderByDesc(User::getUpdatedAt);
+
+        Page<User> result = userMapper.selectPage(pageParam, wrapper);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "通过营养师审核")
+    @PostMapping("/nutritionist/approve/{userId}")
+    public Result<Boolean> approveNutritionist(@PathVariable Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+
+        user.setNutritionistStatus("approved");
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+
+        return Result.success(true);
+    }
+
+    @Operation(summary = "拒绝营养师审核")
+    @PostMapping("/nutritionist/reject/{userId}")
+    public Result<Boolean> rejectNutritionist(@PathVariable Long userId, @RequestBody Map<String, String> params) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+
+        user.setNutritionistStatus("rejected");
+        user.setNutritionistRejectReason(params.get("reason"));
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+
+        return Result.success(true);
+    }
+
+    @Operation(summary = "获取待审核营养师数量")
+    @PostMapping("/nutritionist/pending-count")
+    public Result<Map<String, Object>> getPendingNutritionistCount() {
+        Long count = userMapper.selectCount(new LambdaQueryWrapper<User>()
+            .eq(User::getNutritionistStatus, "pending")
+            .isNotNull(User::getRealName));
+        Map<String, Object> result = new HashMap<>();
+        result.put("count", count);
+        return Result.success(result);
+    }
+
     // ========== 成长规划师相关接口 ==========
 
     /**

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReviewCenterController.java

@@ -93,6 +93,9 @@ public class ReviewCenterController {
         counts.put("hierarchyChangePending", supplyHierarchyChangeRequestMapper.selectCount(
                 new QueryWrapper<SupplyHierarchyChangeRequest>().eq("status", "pending")).intValue());
 
+        counts.put("nutritionistPending", userMapper.selectCount(
+                new QueryWrapper<User>().eq("nutritionist_status", "pending")).intValue());
+
         return Result.success(counts);
     }
 }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -138,6 +138,12 @@ public class User implements Serializable {
     // 管家认证通过时间
     private Date butlerApprovedAt;
 
+    // 营养师审核状态: pending/approved/rejected
+    private String nutritionistStatus;
+
+    // 营养师审核拒绝原因
+    private String nutritionistRejectReason;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 2 - 0
cfc-backend/src/main/resources/schema.sql

@@ -33,6 +33,8 @@ CREATE TABLE IF NOT EXISTS users (
     butler_status VARCHAR(20) COMMENT '管家状态: null/pending/approved/rejected',
     butler_info TEXT COMMENT '管家资质信息(JSON)',
     butler_approved_at DATETIME COMMENT '管家认证通过时间',
+    nutritionist_status VARCHAR(20) DEFAULT NULL COMMENT '营养师审核状态: pending/approved/rejected',
+    nutritionist_reject_reason VARCHAR(500) DEFAULT NULL COMMENT '营养师审核拒绝原因',
     INDEX idx_openid (openid),
     INDEX idx_family_id (family_id),
     INDEX idx_phone (phone)

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

@@ -380,6 +380,40 @@ export function rejectGuide(userId, reason) {
   })
 }
 
+// 获取营养师审核列表
+export function getNutritionistApplications(params) {
+  return request({
+    url: '/api/admin/nutritionist/applications',
+    method: 'post',
+    data: params
+  })
+}
+
+// 通过营养师审核
+export function approveNutritionist(userId) {
+  return request({
+    url: `/api/admin/nutritionist/approve/${userId}`,
+    method: 'post'
+  })
+}
+
+// 拒绝营养师审核
+export function rejectNutritionist(userId, reason) {
+  return request({
+    url: `/api/admin/nutritionist/reject/${userId}`,
+    method: 'post',
+    data: { reason }
+  })
+}
+
+// 获取待审核营养师数量
+export function getPendingNutritionistCount() {
+  return request({
+    url: '/api/admin/nutritionist/pending-count',
+    method: 'post'
+  })
+}
+
 // 获取待审核成长规划师数量
 export function getPendingGuideCount() {
   return request({

+ 8 - 1
cfc-web/src/views/admin/ReviewCenter.vue

@@ -25,6 +25,9 @@
         <el-tab-pane label="规划师审核" name="guide">
           <guide-audit ref="guide" />
         </el-tab-pane>
+        <el-tab-pane label="营养师审核" name="nutritionist">
+          <nutritionist-audit ref="nutritionist" />
+        </el-tab-pane>
         <el-tab-pane label="服务商审核" name="vendor">
           <vendor-audit ref="vendor" />
         </el-tab-pane>
@@ -63,12 +66,14 @@ import ActivityAudit from './review/ActivityAudit.vue'
 import ActivityRegistrationAudit from './review/ActivityRegistrationAudit.vue'
 import WithdrawAudit from './review/WithdrawAudit.vue'
 import HierarchyAudit from './review/HierarchyAudit.vue'
+import NutritionistAudit from './review/NutritionistAudit.vue'
 
 export default {
   name: 'ReviewCenter',
   components: {
     ArticleAudit, ProductAudit, GuideAudit, VendorAudit, ButlerAudit,
-    PackageAudit, ActivityAudit, ActivityRegistrationAudit, WithdrawAudit, HierarchyAudit
+    PackageAudit, ActivityAudit, ActivityRegistrationAudit, WithdrawAudit, HierarchyAudit,
+    NutritionistAudit
   },
   data() {
     return {
@@ -85,6 +90,7 @@ export default {
         { key: 'activityRegistration', label: '报名审核', icon: '📝', count: 0 },
         { key: 'withdraw', label: '提现审核', icon: '💰', count: 0 },
         { key: 'hierarchy', label: '更换上级', icon: '🔗', count: 0 },
+        { key: 'nutritionist', label: '营养师审核', icon: '🥗', count: 0 },
       ]
     }
   },
@@ -104,6 +110,7 @@ export default {
             const keyMap = {
               article: 'articlePending', product: 'productPending', guide: 'guidePending',
               vendor: 'vendorPending', butler: 'butlerPending', package: 'packagePending',
+              nutritionist: 'nutritionistPending',
               activity: 'activityPending', activityRegistration: 'activityRegistrationPending',
               withdraw: 'withdrawPending', hierarchy: 'hierarchyChangePending'
             }

+ 106 - 0
cfc-web/src/views/admin/review/NutritionistAudit.vue

@@ -0,0 +1,106 @@
+<template>
+  <div>
+    <div class="filter-bar">
+      <el-select v-model="statusFilter" placeholder="审核状态" @change="loadData">
+        <el-option label="全部" value="" />
+        <el-option label="待审核" value="pending" />
+        <el-option label="已通过" value="approved" />
+        <el-option label="已拒绝" value="rejected" />
+      </el-select>
+      <el-input v-model="keyword" placeholder="搜索..." prefix-icon="el-icon-search" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" style="width:200px" />
+      <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
+    </div>
+    <div class="table-scroll-wrap"><div style="overflow:auto;max-height:calc(100vh - 300px);">
+    <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;">
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="nickname" label="昵称" width="120" />
+      <el-table-column prop="realName" label="真实姓名" width="120" />
+      <el-table-column prop="phone" label="手机号" width="140" />
+      <el-table-column label="审核状态" width="100">
+        <template slot-scope="{ row }">
+          <el-tag :type="statusType(row.nutritionistStatus)">{{ statusLabel(row.nutritionistStatus) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="180" fixed="right">
+        <template slot-scope="{ row }">
+          <template v-if="row.nutritionistStatus === 'pending'">
+            <el-button size="mini" type="success" @click="handleApprove(row)">通过</el-button>
+            <el-button size="mini" type="danger" @click="showReject(row)">拒绝</el-button>
+          </template>
+          <span v-else class="no-op">—</span>
+        </template>
+      </el-table-column>
+    </el-table></div></div>
+
+    <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="400px">
+      <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
+      <span slot="footer">
+        <el-button @click="rejectDialogVisible = false">取消</el-button>
+        <el-button type="danger" @click="confirmReject">确认拒绝</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getNutritionistApplications, approveNutritionist, rejectNutritionist } from '@/api/admin'
+
+export default {
+  name: 'NutritionistAudit',
+  data() {
+    return {
+      list: [], loading: false, keyword: '', statusFilter: 'pending',
+      rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
+    }
+  },
+  mounted() { this.loadData() },
+  methods: {
+    statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
+    statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
+    handleSearch() { this.loadData() },
+    async loadData() {
+      this.loading = true
+      try {
+        const res = await getNutritionistApplications({
+          status: this.statusFilter || undefined,
+          keyword: this.keyword || undefined, page: 1, size: 50
+        })
+        if (res.data && res.data.records) {
+          this.list = res.data.records
+        } else if (res.data && Array.isArray(res.data)) {
+          this.list = res.data
+        } else {
+          this.list = []
+        }
+      } catch (e) {
+        console.error(e)
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    async handleApprove(row) {
+      try {
+        await approveNutritionist(row.id)
+        this.$message.success('已通过')
+        this.loadData()
+      } catch (e) { this.$message.error('操作失败') }
+    },
+    showReject(row) { this.rejectTarget = row; this.rejectReason = ''; this.rejectDialogVisible = true },
+    async confirmReject() {
+      if (!this.rejectReason) return this.$message.warning('请填写拒绝原因')
+      try {
+        await rejectNutritionist(this.rejectTarget.id, this.rejectReason)
+        this.$message.success('已拒绝')
+        this.rejectDialogVisible = false
+        this.loadData()
+      } catch (e) { this.$message.error('操作失败') }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
+.no-op { color: #ccc; }
+</style>