فهرست منبع

feat(cfc-web): 内容维度阈值显隐配置页(A-前端)

新增 ContentDimensionThresholdEditor 可复用组件与 API 封装,
支持五维/子项/自定义指标维度阈值配置(batch-save 整表替换)。
挂载到文章/活动/商品编辑页;小游戏无管理页,新增最小化
MiniGameThreshold 挂载页(复用 /api/mini-game/all)。
小程序端核验:list 接口均由后端从 JWT 解析家庭,服务端过滤,无需改动。
Sisyphus Agent 2 هفته پیش
والد
کامیت
e3ef0cc6cf

+ 41 - 0
cfc-web/src/api/content-dimension-threshold.js

@@ -0,0 +1,41 @@
+import request from '@/utils/request'
+
+// 内容维度阈值显隐配置 API
+// 契约(后端已实现,勿改):
+// - GET    /api/admin/content-dimension-threshold/list?contentType=PRODUCT&contentId=123
+// - POST   /api/admin/content-dimension-threshold/batch-save
+// - DELETE /api/admin/content-dimension-threshold/{id}
+
+// 查询某内容项的维度阈值列表(含 id)
+export function getContentDimensionThresholdList(params) {
+  return request({
+    url: '/api/admin/content-dimension-threshold/list',
+    method: 'get',
+    params
+  })
+}
+
+// 整表替换保存(contentType/contentId/items 一次性提交),返回保存后的阈值列表(含 id)
+export function batchSaveContentDimensionThreshold(data) {
+  return request({
+    url: '/api/admin/content-dimension-threshold/batch-save',
+    method: 'post',
+    data
+  })
+}
+
+// 单条删除
+export function deleteContentDimensionThreshold(id) {
+  return request({
+    url: `/api/admin/content-dimension-threshold/${id}`,
+    method: 'delete'
+  })
+}
+
+// 管理端获取全部小游戏(用于 MINIGAME 维度阈值配置的挂载点)
+export function getAllMiniGames() {
+  return request({
+    url: '/api/mini-game/all',
+    method: 'post'
+  })
+}

+ 285 - 0
cfc-web/src/components/ContentDimensionThresholdEditor.vue

@@ -0,0 +1,285 @@
+<template>
+  <div class="c-dt-editor" v-loading="loading">
+    <div class="c-dt-title">维度阈值显隐配置(五维/子项/自定义指标)</div>
+    <div class="c-dt-hint">小于阈值显示、≥阈值隐藏;家庭取平均。可针对商品/文章/活动/小游戏分别配置。</div>
+
+    <el-table :data="rows" border size="small" class="c-dt-table">
+      <el-table-column label="维度" min-width="220">
+        <template slot-scope="scope">
+          <template v-if="scope.row.isCustom">
+            <el-input v-model="scope.row.dimensionCode" placeholder="维度码(如 CUSTOM_X)" size="small" class="c-dt-input" />
+            <el-input v-model="scope.row.dimensionName" placeholder="指标名称" size="small" class="c-dt-input c-dt-name-input mt-6" />
+          </template>
+          <el-select
+            v-else
+            v-model="scope.row.dimensionCode"
+            placeholder="选择维度"
+            filterable
+            size="small"
+            class="c-dt-dim-select"
+            @change="onDimensionChange(scope.row)"
+          >
+            <el-option-group
+              v-for="g in dimensionGroups"
+              :key="g.label"
+              :label="g.label"
+            >
+              <el-option
+                v-for="opt in g.options"
+                :key="opt.dimensionCode"
+                :label="opt.dimensionName"
+                :value="opt.dimensionCode"
+              />
+            </el-option-group>
+          </el-select>
+        </template>
+      </el-table-column>
+      <el-table-column label="阈值 (0-100)" width="150">
+        <template slot-scope="scope">
+          <el-input-number
+            v-model="scope.row.threshold"
+            :min="0"
+            :max="100"
+            controls-position="right"
+            size="small"
+            class="c-dt-number"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="80" align="center">
+        <template slot-scope="scope">
+          <el-button type="text" size="mini" class="c-dt-del" @click="removeRow(scope.$index)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div class="c-dt-empty" v-if="!loading && rows.length === 0">
+      <span>暂无配置</span>
+      <el-button type="primary" size="small" icon="el-icon-plus" @click="addRow(0)">添加维度</el-button>
+      <el-button size="small" @click="addRow(1)">添加自定义指标</el-button>
+    </div>
+
+    <div class="c-dt-actions" v-if="rows.length > 0">
+      <el-button type="primary" @click="handleSave" :loading="saving">保存配置</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getDimensionTree } from '@/api/dimension'
+import { getContentDimensionThresholdList, batchSaveContentDimensionThreshold } from '@/api/content-dimension-threshold'
+
+export default {
+  name: 'ContentDimensionThresholdEditor',
+  props: {
+    contentType: {
+      type: String,
+      required: true,
+      validator: function(val) {
+        return ['PRODUCT', 'ARTICLE', 'ACTIVITY', 'MINIGAME'].indexOf(val) >= 0
+      }
+    },
+    contentId: {
+      type: [Number, String],
+      required: true
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      saving: false,
+      flatOptions: [],
+      rows: []
+    }
+  },
+  computed: {
+    dimensionGroups() {
+      const map = {}
+      this.flatOptions.forEach(function(opt) {
+        var g = map[opt.group]
+        if (!g) {
+          g = []
+          map[opt.group] = g
+        }
+        g.push(opt)
+      })
+      var groups = []
+      Object.keys(map).forEach(function(label) {
+        groups.push({ label: label, options: map[label] })
+      })
+      return groups
+    }
+  },
+  created() {
+    this.loadDimensionTree()
+    this.loadList()
+  },
+  methods: {
+    async loadDimensionTree() {
+      try {
+        var res = await getDimensionTree()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.flatOptions = this.flattenTree(res.data)
+        }
+      } catch (e) {
+        console.error('[ContentDimensionThreshold] 加载维度树失败', e)
+      }
+    },
+    flattenTree(nodes) {
+      var result = []
+      var walk = function(list, groupName) {
+        ;(list || []).forEach(function(n) {
+          var g = groupName || (n.dimensionName || '其他')
+          if (n.dimensionCode) {
+            result.push({ dimensionCode: n.dimensionCode, dimensionName: n.dimensionName, group: g })
+          }
+          if (n.children && n.children.length) {
+            walk(n.children, g)
+          }
+        })
+      }
+      walk(nodes, null)
+      return result
+    },
+    async loadList() {
+      if (!this.contentId) return
+      this.loading = true
+      try {
+        var res = await getContentDimensionThresholdList({
+          contentType: this.contentType,
+          contentId: Number(this.contentId)
+        })
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.rows = res.data.map(function(item) {
+            return {
+              id: item.id,
+              dimensionCode: item.dimensionCode || '',
+              dimensionName: item.dimensionName || '',
+              threshold: item.threshold != null ? Number(item.threshold) : 60,
+              isCustom: item.isCustom === 1 ? 1 : 0,
+              enabled: item.enabled != null ? Number(item.enabled) : 1,
+              _key: item.id
+            }
+          })
+        } else {
+          this.rows = []
+        }
+      } catch (e) {
+        console.error('[ContentDimensionThreshold] 加载阈值列表失败', e)
+        this.rows = []
+      } finally {
+        this.loading = false
+      }
+    },
+    addRow(isCustom) {
+      var row = {
+        isCustom: isCustom === 1 ? 1 : 0,
+        dimensionCode: '',
+        dimensionName: '',
+        threshold: 60,
+        _key: Date.now() + Math.random()
+      }
+      this.rows.push(row)
+    },
+    removeRow(idx) {
+      this.rows.splice(idx, 1)
+    },
+    onDimensionChange(row) {
+      var opt = this.flatOptions.find(function(o) { return o.dimensionCode === row.dimensionCode })
+      if (opt) {
+        row.dimensionName = opt.dimensionName
+      }
+    },
+    async handleSave() {
+      this.saving = true
+      try {
+        var items = this.rows.map(function(r) {
+          return {
+            dimensionCode: r.dimensionCode,
+            dimensionName: r.dimensionName,
+            threshold: Number(r.threshold) || 0,
+            isCustom: r.isCustom === 1 ? 1 : 0
+          }
+        })
+        var res = await batchSaveContentDimensionThreshold({
+          contentType: this.contentType,
+          contentId: Number(this.contentId),
+          items: items
+        })
+        if (res.code === 200) {
+          this.$message.success('保存成功')
+          await this.loadList()
+        } else {
+          this.$message.error(res.message || '保存失败')
+        }
+      } catch (e) {
+        this.$message.error('保存失败')
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.c-dt-editor {
+  margin-top: 16px;
+  padding: 16px;
+  border: 1px solid var(--adm-border-base, #e4e7ed);
+  border-radius: 4px;
+  background: #fafbfc;
+}
+.c-dt-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--adm-text-88, #303133);
+  margin-bottom: 4px;
+}
+.c-dt-hint {
+  font-size: 12px;
+  color: var(--adm-text-45, #909399);
+  margin-bottom: 12px;
+}
+.c-dt-table {
+  width: 100%;
+}
+.c-dt-table /deep/ .el-table__cell {
+  padding: 6px 0;
+  box-sizing: border-box;
+}
+.c-dt-dim-select {
+  width: 100%;
+}
+.c-dt-number {
+  width: 100%;
+}
+.c-dt-number /deep/ .el-input__inner {
+  text-align: center;
+}
+.c-dt-input {
+  width: 100%;
+}
+.c-dt-name-input {
+  margin-top: 4px;
+}
+.c-dt-del {
+  color: #f56c6c;
+}
+.c-dt-empty {
+  text-align: center;
+  padding: 20px 0;
+  color: var(--adm-text-45, #909399);
+  font-size: 14px;
+}
+.c-dt-empty .el-button {
+  margin-left: 8px;
+}
+.c-dt-actions {
+  margin-top: 12px;
+  text-align: right;
+}
+.mt-6 {
+  margin-top: 6px;
+}
+</style>

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

@@ -725,6 +725,12 @@ const routes = [
         component: () => import('@/views/admin/ActivityCheckinManage.vue'),
         meta: { title: '活动签到审核', perm: 'activity:checkin' }
       },
+      {
+        path: 'minigame-threshold',
+        name: 'MiniGameThreshold',
+        component: () => import('@/views/admin/MiniGameThreshold.vue'),
+        meta: { title: '小游戏维度阈值', perm: 'admin:all' }
+      },
       {
         path: 'poster-template-manage',
         name: 'PosterTemplateManage',

+ 6 - 1
cfc-web/src/views/admin/ActivityEdit.vue

@@ -130,6 +130,10 @@
           <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
         </el-form-item>
 
+        <el-form-item v-if="$route.query.id" label="维度阈值显隐">
+          <content-dimension-threshold-editor content-type="ACTIVITY" :content-id="$route.query.id" />
+        </el-form-item>
+
         <el-form-item label="活动标签">
           <el-input v-model="form.tags" :disabled="readonly" placeholder="多个标签用逗号分隔;留空则在提交审核时自动生成" />
         </el-form-item>
@@ -281,6 +285,7 @@
 
 <script>
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
+import ContentDimensionThresholdEditor from '@/components/ContentDimensionThresholdEditor.vue'
 import { getActivityDetail, createActivity, updateActivity, publishActivity, submitActivityReview, reDraftActivity, getRegistrationRoster } from '@/api/activity'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
@@ -290,7 +295,7 @@ import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert,
 
 export default {
   name: 'ActivityEdit',
-  components: { DimensionWeightPicker, Editor, Toolbar, AssetPicker },
+  components: { DimensionWeightPicker, ContentDimensionThresholdEditor, Editor, Toolbar, AssetPicker },
   data() {
     return {
       isEdit: false,

+ 6 - 1
cfc-web/src/views/admin/ArticleEdit.vue

@@ -136,6 +136,10 @@
           <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
         </el-form-item>
 
+        <el-form-item v-if="form.id" label="维度阈值显隐">
+          <content-dimension-threshold-editor content-type="ARTICLE" :content-id="form.id" />
+        </el-form-item>
+
         <el-form-item label="浏览权限">
           <el-radio-group v-model="form.visibility" :disabled="readonly">
             <el-radio label="public">公开</el-radio>
@@ -164,6 +168,7 @@
 
 <script>
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
+import ContentDimensionThresholdEditor from '@/components/ContentDimensionThresholdEditor.vue'
 import {
   adminArticleCreate,
   adminArticleUpdate,
@@ -182,7 +187,7 @@ import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert,
 
 export default {
   name: 'ArticleEdit',
-  components: { DimensionWeightPicker, Editor, Toolbar, AssetPicker },
+  components: { DimensionWeightPicker, ContentDimensionThresholdEditor, Editor, Toolbar, AssetPicker },
   data() {
     return {
       isEdit: false,

+ 105 - 0
cfc-web/src/views/admin/MiniGameThreshold.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="minigame-threshold admin-page">
+    <el-card>
+      <PageHeader slot="header" title="小游戏维度阈值显隐配置">
+        <el-select
+          v-model="selectedGameId"
+          placeholder="选择小游戏"
+          filterable
+          clearable
+          class="game-select"
+          @change="onGameChange"
+        >
+          <el-option
+            v-for="g in games"
+            :key="g.id"
+            :label="(g.gameName || '') + '(' + (g.gameCode || '') + ')'"
+            :value="g.id"
+          />
+        </el-select>
+      </PageHeader>
+
+      <div class="game-body">
+        <div v-if="!selectedGameId" class="game-empty">
+          <el-empty description="请先选择一个小游戏,再配置维度阈值显隐" />
+        </div>
+        <content-dimension-threshold-editor
+          v-else
+          content-type="MINIGAME"
+          :content-id="selectedGameId"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import ContentDimensionThresholdEditor from '@/components/ContentDimensionThresholdEditor.vue'
+import { getAllMiniGames } from '@/api/content-dimension-threshold'
+
+export default {
+  name: 'MiniGameThreshold',
+  components: { ContentDimensionThresholdEditor },
+  data() {
+    return {
+      games: [],
+      selectedGameId: null,
+      loading: false
+    }
+  },
+  created() {
+    this.loadGames()
+  },
+  methods: {
+    async loadGames() {
+      this.loading = true
+      try {
+        const res = await getAllMiniGames()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.games = res.data
+        }
+      } catch (e) {
+        this.$message.error('加载小游戏列表失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    onGameChange(val) {
+      this.selectedGameId = val || null
+    }
+  }
+}
+</script>
+
+<style scoped>
+.minigame-threshold {
+  padding: 24px;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.minigame-threshold > .el-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.minigame-threshold > .el-card /deep/ .el-card__body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.game-select {
+  width: 320px;
+}
+.game-body {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+}
+.game-empty {
+  padding: 40px 0;
+}
+</style>

+ 6 - 1
cfc-web/src/views/admin/ProductEdit.vue

@@ -297,6 +297,10 @@
           <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" />
         </el-form-item>
 
+        <el-form-item v-if="form.id" label="维度阈值显隐">
+          <content-dimension-threshold-editor content-type="PRODUCT" :content-id="form.id" />
+        </el-form-item>
+
         <el-form-item label="SKU规格">
           <ProductSkuEditor ref="skuEditor" :productId="form.id" />
         </el-form-item>
@@ -382,6 +386,7 @@
 import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveBundleItems, getBundleItems, getApprovedGuides, saveProductGift, getProductGiftList, getCategoryTree } from '@/api/admin'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
+import ContentDimensionThresholdEditor from '@/components/ContentDimensionThresholdEditor.vue'
 import ProductSkuEditor from './components/ProductSkuEditor.vue'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
@@ -390,7 +395,7 @@ import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert,
 
 export default {
   name: 'ProductEdit',
-  components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar, AssetPicker },
+  components: { DimensionWeightPicker, ContentDimensionThresholdEditor, ProductSkuEditor, Editor, Toolbar, AssetPicker },
   data() {
     return {
       form: {