Bladeren bron

feat: 勋章管理后台页面 + 家庭排名 + 勋章墙导航

- cfc-web: 新增 BadgeManage.vue 勋章管理 CRUD 页面、badge.js API 封装、路由、侧边栏菜单
- cfc-frontend: badge.vue 增加家庭勋章排行展示
- cfc-frontend: rewards.vue 增加勋章墙导航入口
Sisyphus 1 maand geleden
bovenliggende
commit
7921641ac1

+ 119 - 1
cfc-frontend/pages/rewards/badge.vue

@@ -16,6 +16,26 @@
       </view>
     </view>
 
+    <!-- 家庭勋章排行 -->
+    <view class="ranking-section" v-if="familyRanking.length > 0">
+      <view class="ranking-title">家庭勋章排行</view>
+      <view class="ranking-list">
+        <view class="ranking-item" v-for="(item, index) in familyRanking" :key="index">
+          <text class="ranking-index">{{ index + 1 }}</text>
+          <image class="ranking-avatar" v-if="item.avatar" :src="item.avatar" mode="aspectFill" />
+          <text class="ranking-avatar-placeholder" v-else>{{ item.memberName ? item.memberName.substring(0,1) : '?' }}</text>
+          <view class="ranking-info">
+            <text class="ranking-name">{{ item.memberName || '未知' }}</text>
+            <text class="ranking-role">{{ item.role === 'child' ? '孩子' : '家长' }}</text>
+          </view>
+          <view class="ranking-count">
+            <text class="ranking-num">{{ item.badgeCount || 0 }}</text>
+            <text class="ranking-label">枚勋章</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
     <!-- 分类筛选 -->
     <view class="filter-bar">
       <view
@@ -78,7 +98,7 @@
 </template>
 
 <script>
-import { getActiveBadges, getChildBadges, getChildBadgeStats, toggleBadgeFavorite } from '../../utils/api.js'
+import { getActiveBadges, getChildBadges, getChildBadgeStats, toggleBadgeFavorite, getFamilyBadgeRanking } from '../../utils/api.js'
 
 export default {
   data() {
@@ -102,6 +122,7 @@ export default {
       levelMap: { 'bronze': '铜牌', 'silver': '银牌', 'gold': '金牌', 'diamond': '钻石' },
       rarityMap: { 'common': '普通', 'rare': '稀有', 'epic': '史诗', 'legendary': '传说' },
       categoryMap: { '学习': '📚', '运动': '⚽', '家务': '🧹', '阅读': '📖', '打卡': '✅', '综合': '⭐' },
+      familyRanking: [],
       showDetail: false,
       detailBadge: null,
       detailEarned: false,
@@ -143,6 +164,7 @@ export default {
         this.totalBadges = this.badges.length
         this.buildEarnedMap()
         this.applyFilter()
+        this.loadFamilyRanking()
       } catch (e) {
         console.error('加载勋章数据失败', e)
       }
@@ -206,6 +228,16 @@ export default {
         console.error('操作失败', e)
       }
     },
+    async loadFamilyRanking() {
+      var familyId = uni.getStorageSync('familyId')
+      if (!familyId) return
+      try {
+        var res = await getFamilyBadgeRanking(familyId)
+        this.familyRanking = res.data || []
+      } catch (e) {
+        console.error('加载家庭排名失败', e)
+      }
+    },
     getBadgeSymbol(badge) {
       if (!badge) return '🏅'
       const symbols = {
@@ -318,6 +350,92 @@ export default {
   color: #F97316;
   margin-top: 4rpx;
 }
+.ranking-section {
+  background: #fff;
+  margin: 20rpx;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.ranking-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 16rpx;
+  padding-left: 8rpx;
+  border-left: 6rpx solid #F97316;
+}
+.ranking-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+.ranking-item {
+  display: flex;
+  align-items: center;
+  padding: 12rpx 8rpx;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.ranking-index {
+  width: 40rpx;
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #999;
+  text-align: center;
+}
+.ranking-item:first-child .ranking-index { color: #F97316; }
+.ranking-item:nth-child(2) .ranking-index { color: #666; }
+.ranking-item:nth-child(3) .ranking-index { color: #8B4513; }
+.ranking-avatar {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  margin: 0 16rpx;
+}
+.ranking-avatar-placeholder {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  margin: 0 16rpx;
+  background: #F97316;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28rpx;
+  text-align: center;
+  line-height: 60rpx;
+}
+.ranking-info {
+  flex: 1;
+}
+.ranking-name {
+  display: block;
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+.ranking-role {
+  display: block;
+  font-size: 20rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.ranking-count {
+  text-align: center;
+}
+.ranking-num {
+  display: block;
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.ranking-label {
+  display: block;
+  font-size: 20rpx;
+  color: #999;
+}
 .empty-state {
   text-align: center;
   padding: 120rpx 30rpx;

+ 32 - 0
cfc-frontend/pages/rewards/rewards.vue

@@ -1,5 +1,11 @@
 <template>
   <view class="container">
+    <view class="nav-bar">
+      <view class="nav-item" @click="goToBadge">
+        <text class="nav-icon">🏅</text>
+        <text class="nav-text">勋章墙</text>
+      </view>
+    </view>
     <view class="reward-list" v-if="rewards.length > 0">
       <view v-for="reward in rewards" :key="reward.id" class="reward-item">
         <view class="reward-info">
@@ -159,6 +165,9 @@ export default {
         }
       })
     },
+    goToBadge() {
+      uni.navigateTo({ url: '/pages/rewards/badge' })
+    },
     async createReward() {
       if (!this.newReward.title) {
         uni.showToast({ title: '请填写心愿名称', icon: 'none' })
@@ -203,6 +212,29 @@ export default {
   padding-bottom: 120rpx;
 }
 
+.nav-bar {
+  display: flex;
+  margin-bottom: 20rpx;
+  gap: 16rpx;
+}
+.nav-item {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 24rpx;
+  background: #fff;
+  border-radius: 12rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.nav-icon {
+  font-size: 32rpx;
+  margin-right: 8rpx;
+}
+.nav-text {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+
 .reward-item {
   background: #fff;
   border-radius: 16rpx;

+ 21 - 0
cfc-web/src/api/badge.js

@@ -0,0 +1,21 @@
+import request from '@/utils/request'
+
+export function getBadgeList() {
+  return request({ url: '/api/badges/all', method: 'post' })
+}
+
+export function getBadgeDetail(id) {
+  return request({ url: '/api/badges/detail', method: 'post', data: { id } })
+}
+
+export function createBadge(data) {
+  return request({ url: '/api/badges/create', method: 'post', data })
+}
+
+export function updateBadge(data) {
+  return request({ url: '/api/badges/update', method: 'post', data })
+}
+
+export function deleteBadge(id) {
+  return request({ url: '/api/badges/delete', method: 'post', data: { id } })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -595,6 +595,12 @@ const routes = [
         name: 'EcomSupplierManage',
         component: () => import('@/views/admin/EcomSupplierManage'),
         meta: { title: '电商供应商管理', perm: 'ecom:supplier' }
+      },
+      {
+        path: 'badge-manage',
+        name: 'BadgeManage',
+        component: () => import('@/views/admin/BadgeManage'),
+        meta: { title: '勋章管理', perm: 'system:config' }
       }
     ]
   }

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -266,6 +266,7 @@ export default {
                 { path: '/users', label: '用户管理', icon: 'el-icon-user', perm: 'system:users' },
                 { path: '/operation-logs', label: '操作日志', icon: 'el-icon-s-order', perm: 'system:logs' },
                 { path: '/membership-center', label: '会员中心', icon: 'el-icon-s-custom', perm: 'system:config' },
+                { path: '/badge-manage', label: '勋章管理', icon: 'el-icon-medal', perm: 'system:config' },
               ]},
             // --- 配置中心 ---
             { title: '配置中心', icon: 'el-icon-setting', perm: 'system:config',

+ 347 - 0
cfc-web/src/views/admin/BadgeManage.vue

@@ -0,0 +1,347 @@
+<template>
+  <div class="badge-manage-page">
+    <el-card>
+      <div slot="header" class="clearfix">
+        <span>勋章管理</span>
+        <div style="float:right; display:flex; gap:10px;">
+          <el-button type="primary" size="small" @click="showAddDialog">新增勋章</el-button>
+        </div>
+      </div>
+
+      <!-- 搜索 -->
+      <el-form :model="filters" inline size="small" style="margin-bottom:16px;">
+        <el-form-item label="名称">
+          <el-input v-model="filters.name" placeholder="搜索勋章名称" clearable @input="onSearchInput" />
+        </el-form-item>
+        <el-form-item label="分类">
+          <el-select v-model="filters.category" placeholder="全部分类" clearable @change="fetchList" style="width:140px;">
+            <el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="等级">
+          <el-select v-model="filters.level" placeholder="全部等级" clearable @change="fetchList" style="width:140px;">
+            <el-option v-for="c in levelOptions" :key="c.value" :label="c.label" :value="c.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+
+      <el-table :data="list" border stripe v-loading="loading" empty-text="暂无数据" style="width:100%" :max-height="tableHeight">
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="name" label="名称" min-width="120" />
+        <el-table-column prop="category" label="分类" width="90" />
+        <el-table-column label="等级" width="80">
+          <template slot-scope="{ row }">
+            {{ levelMap[row.level] || row.level || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="稀有度" width="80">
+          <template slot-scope="{ row }">
+            {{ rarityMap[row.rarity] || row.rarity || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="启用" width="80" align="center">
+          <template slot-scope="{ row }">
+            <el-switch
+              :value="row.isActive === 1"
+              active-color="#67C23A"
+              @change="onToggleActive(row)"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column prop="sortOrder" label="排序" width="60" align="center" />
+        <el-table-column prop="createdAt" label="创建时间" width="160" />
+        <el-table-column label="操作" width="160" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button type="text" @click="editRow(row)">编辑</el-button>
+            <el-popconfirm title="确定删除此勋章吗?" @confirm="deleteRow(row)">
+              <el-button type="text" style="color:#f56c6c" slot="reference">删除</el-button>
+            </el-popconfirm>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog :visible.sync="dialog.visible" :title="dialog.isEdit ? '编辑勋章' : '新增勋章'" width="600px" :close-on-click-modal="false">
+      <el-form :model="dialog.form" label-width="110px" size="small">
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="名称" required>
+              <el-input v-model="dialog.form.name" placeholder="勋章名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="分类" required>
+              <el-select v-model="dialog.form.category" placeholder="请选择" style="width:100%">
+                <el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="等级">
+              <el-select v-model="dialog.form.level" placeholder="请选择" style="width:100%">
+                <el-option v-for="c in levelOptions" :key="c.value" :label="c.label" :value="c.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="稀有度">
+              <el-select v-model="dialog.form.rarity" placeholder="请选择" style="width:100%">
+                <el-option v-for="c in rarityOptions" :key="c.value" :label="c.label" :value="c.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="图标URL">
+          <el-input v-model="dialog.form.icon" placeholder="勋章图标地址" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input type="textarea" :rows="3" v-model="dialog.form.description" placeholder="勋章描述..." />
+        </el-form-item>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="触发类型">
+              <el-input v-model="dialog.form.triggerType" placeholder="如: micro_action_count" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="触发阈值">
+              <el-input-number v-model="dialog.form.threshold" :min="0" style="width:100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="排序">
+              <el-input-number v-model="dialog.form.sortOrder" :min="0" style="width:100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="过期天数">
+              <el-input-number v-model="dialog.form.expireDays" :min="0" style="width:100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="是否启用">
+              <el-switch v-model="dialog.form._active" active-color="#67C23A" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="需要审批">
+              <el-switch v-model="dialog.form._needApproval" active-color="#67C23A" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialog.visible = false" size="small">取消</el-button>
+        <el-button type="primary" @click="saveDialog" size="small" :loading="dialog.saving">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getBadgeList, createBadge, updateBadge, deleteBadge } from '@/api/badge'
+
+export default {
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 300
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      list: [],
+      searchTimer: null,
+      filters: { name: '', category: '', level: '' },
+      categoryOptions: [
+        { value: '学习', label: '学习' },
+        { value: '运动', label: '运动' },
+        { value: '家务', label: '家务' },
+        { value: '阅读', label: '阅读' },
+        { value: '打卡', label: '打卡' },
+        { value: '综合', label: '综合' }
+      ],
+      levelOptions: [
+        { value: 'bronze', label: '铜牌' },
+        { value: 'silver', label: '银牌' },
+        { value: 'gold', label: '金牌' },
+        { value: 'diamond', label: '钻石' }
+      ],
+      rarityOptions: [
+        { value: 'common', label: '普通' },
+        { value: 'rare', label: '稀有' },
+        { value: 'epic', label: '史诗' },
+        { value: 'legendary', label: '传说' }
+      ],
+      levelMap: { bronze: '铜牌', silver: '银牌', gold: '金牌', diamond: '钻石' },
+      rarityMap: { common: '普通', rare: '稀有', epic: '史诗', legendary: '传说' },
+      dialog: {
+        visible: false,
+        isEdit: false,
+        saving: false,
+        form: {
+          id: null,
+          name: '',
+          description: '',
+          icon: '',
+          category: '',
+          level: '',
+          rarity: '',
+          triggerType: '',
+          threshold: 0,
+          sortOrder: 0,
+          expireDays: 0,
+          _active: true,
+          _needApproval: false
+        }
+      }
+    }
+  },
+  mounted() { this.fetchList() },
+  methods: {
+    onSearchInput() {
+      if (this.searchTimer) clearTimeout(this.searchTimer)
+      this.searchTimer = setTimeout(() => { this.fetchList() }, 300)
+    },
+    async fetchList() {
+      this.loading = true
+      try {
+        const res = await getBadgeList()
+        var data = res.data || []
+        // Apply client-side filters
+        if (this.filters.name) {
+          var q = this.filters.name.toLowerCase()
+          data = data.filter(function(d) { return d.name && d.name.toLowerCase().indexOf(q) !== -1 })
+        }
+        if (this.filters.category) {
+          data = data.filter(function(d) { return d.category === this.filters.category }.bind(this))
+        }
+        if (this.filters.level) {
+          data = data.filter(function(d) { return d.level === this.filters.level }.bind(this))
+        }
+        this.list = data
+      } catch (e) {
+        console.error('加载勋章列表失败', e)
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    showAddDialog() {
+      this.dialog.isEdit = false
+      this.dialog.form = {
+        id: null,
+        name: '',
+        description: '',
+        icon: '',
+        category: '',
+        level: '',
+        rarity: '',
+        triggerType: '',
+        threshold: 0,
+        sortOrder: 0,
+        expireDays: 0,
+        _active: true,
+        _needApproval: false
+      }
+      this.dialog.visible = true
+    },
+    editRow(row) {
+      this.dialog.isEdit = true
+      this.dialog.form = {
+        id: row.id,
+        name: row.name || '',
+        description: row.description || '',
+        icon: row.icon || '',
+        category: row.category || '',
+        level: row.level || '',
+        rarity: row.rarity || '',
+        triggerType: row.triggerType || '',
+        threshold: row.threshold != null ? row.threshold : 0,
+        sortOrder: row.sortOrder != null ? row.sortOrder : 0,
+        expireDays: row.expireDays != null ? row.expireDays : 0,
+        _active: row.isActive === 1,
+        _needApproval: !!row.needApproval
+      }
+      this.dialog.visible = true
+    },
+    async saveDialog() {
+      var form = this.dialog.form
+      if (!form.name) {
+        this.$message.warning('请填写勋章名称')
+        return
+      }
+      if (!form.category) {
+        this.$message.warning('请选择分类')
+        return
+      }
+      this.dialog.saving = true
+      try {
+        var payload = {
+          id: form.id,
+          name: form.name,
+          description: form.description,
+          icon: form.icon,
+          category: form.category,
+          level: form.level,
+          rarity: form.rarity,
+          triggerType: form.triggerType,
+          threshold: form.threshold,
+          sortOrder: form.sortOrder,
+          expireDays: form.expireDays,
+          isActive: form._active ? 1 : 0,
+          needApproval: form._needApproval
+        }
+        if (form.id) {
+          await updateBadge(payload)
+          this.$message.success('更新成功')
+        } else {
+          await createBadge(payload)
+          this.$message.success('创建成功')
+        }
+        this.dialog.visible = false
+        this.fetchList()
+      } catch (e) {
+        console.error('保存失败', e)
+        this.$message.error('保存失败')
+      } finally {
+        this.dialog.saving = false
+      }
+    },
+    async deleteRow(row) {
+      try {
+        await deleteBadge(row.id)
+        this.$message.success('删除成功')
+        this.fetchList()
+      } catch (e) {
+        console.error('删除失败', e)
+        this.$message.error('删除失败')
+      }
+    },
+    async onToggleActive(row) {
+      try {
+        var newVal = row.isActive === 1 ? 0 : 1
+        await updateBadge({ id: row.id, isActive: newVal })
+        this.$message.success(newVal === 1 ? '已启用' : '已禁用')
+        row.isActive = newVal
+      } catch (e) {
+        console.error('操作失败', e)
+        this.$message.error('操作失败')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.badge-manage-page {
+  padding: 16px;
+}
+</style>