Selaa lähdekoodia

feat: add batch shelve + category selection for product management

- Task 1: Batch on/off-shelf
  * Backend: POST /api/admin/product/batch-shelve with {productIds, shelve}
  * ProductService.batchAdminShelve() validates all products are in approvable status before bulk update
  * Frontend: ProductManage.vue - multi-select table row + dropdown '批量上架/下架' with confirmation
  * api/admin.js: batchShelveProduct()

- Task 2: Category selection on product edit page
  * ProductEdit.vue adds categoryId field to form, loaded via getCategoryTree(true)
  * UI uses el-option-group grouped by parent category, searchable/filterable
  * On save/edit, categoryId is included in payload, enabling frontend mall category filtering

- Docs: PROJECT-OVERVIEW.md and API_REFERENCE.md updated with new endpoints
Sisyphus 3 päivää sitten
vanhempi
sitoutus
78bc1b329a

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductController.java

@@ -133,6 +133,24 @@ if (params.get("distributionSystemId") != null) {
         return productService.adminShelve(productId, shelve);
     }
 
+    @Operation(summary = "批量商品上下架")
+    @PostMapping("/batch-shelve")
+    public Result<String> batchShelve(@RequestBody Map<String, Object> params) {
+        List<Long> productIds = (List<Long>) params.get("productIds");
+        Boolean shelve = params.get("shelve") != null
+                ? (Boolean) params.get("shelve")
+                : null;
+
+        if (productIds == null || productIds.isEmpty()) {
+            return Result.error("productIds不能为空");
+        }
+        if (shelve == null) {
+            return Result.error("shelve不能为空");
+        }
+
+        return productService.batchAdminShelve(productIds, shelve);
+    }
+
     @Operation(summary = "商品详情")
     @PostMapping("/detail")
     public Result<ProductDTO> detail(@RequestBody Map<String, Object> params) {

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -370,6 +370,32 @@ wrapper.in(Product::getId, productIds)
         return Result.success(shelve ? "上架成功" : "下架成功");
     }
 
+    /**
+     * 管理员批量上下架商品
+     */
+    public Result<String> batchAdminShelve(List<Long> productIds, Boolean shelve) {
+        if (productIds == null || productIds.isEmpty()) {
+            return Result.error("productIds不能为空");
+        }
+        for (Long productId : productIds) {
+            Product product = productMapper.selectById(productId);
+            if (product == null) {
+                return Result.error("商品不存在: " + productId);
+            }
+            if (!"approved".equals(product.getStatus()) && !"on_shelf".equals(product.getStatus()) && !"off_shelf".equals(product.getStatus())) {
+                return Result.error("仅已审核通过的商品可以上下架,商品 ID: " + productId + " 状态: " + product.getStatus());
+            }
+            if (shelve) {
+                product.setStatus("on_shelf");
+            } else {
+                product.setStatus("off_shelf");
+            }
+            product.setUpdatedAt(new Date());
+            productMapper.updateById(product);
+        }
+        return Result.success(shelve ? "批量上架成功" : "批量下架成功");
+    }
+
     public boolean decreaseStock(Long productId, Integer quantity) {
         if (quantity == null || quantity <= 0) {
             return false;

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

@@ -570,6 +570,14 @@ export function shelveProduct(productId, data) {
   })
 }
 
+export function batchShelveProduct(productIds, shelve) {
+  return request({
+    url: '/api/admin/product/batch-shelve',
+    method: 'post',
+    data: { productIds, shelve }
+  })
+}
+
 export function getProduct(id) {
   return request({
     url: '/api/admin/product/detail',

+ 52 - 3
cfc-web/src/views/admin/ProductEdit.vue

@@ -23,6 +23,15 @@
           </el-select>
         </el-form-item>
 
+        <el-form-item label="商品分类">
+          <el-select v-model="form.categoryId" placeholder="请选择分类(用于前台商城筛选)" clearable filterable style="width: 300px;">
+            <el-option-group v-for="group in categoryGroups" :key="group.label" :label="group.label">
+              <el-option v-for="cat in group.options" :key="cat.id" :label="cat.label" :value="cat.id" />
+            </el-option-group>
+          </el-select>
+          <div style="font-size: 12px; color: #999; margin-top: 4px;">不选则不出现在商城分类筛选中;可在「商品分类管理」中维护分类</div>
+        </el-form-item>
+
         <!-- 测评商品扩展信息 -->
         <template v-if="form.productType === 'assessment'">
           <el-form-item label="测评类型">
@@ -343,7 +352,7 @@
 </template>
 
 <script>
-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 } from '@/api/admin'
+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 ProductSkuEditor from './components/ProductSkuEditor.vue'
@@ -385,7 +394,8 @@ export default {
         totalSessions: 1,
         validityDays: 365,
         guideScope: 'all',
-        sortOrder: 0
+        sortOrder: 0,
+        categoryId: null
       },
       bundleItems: [], // 套餐组成: [{childProductId, childSkuId, quantity, productOptions:[], skuOptions:[]}]
       servicePersons: [], // 线上服务者(deliveryMethod=4): [{userId,name,title,intro,avatar,price,originalPrice}]
@@ -405,6 +415,7 @@ export default {
       // 可选赠品配置:{ [levelCode]: [{giftType,giftProductId,cfValue,sortOrder}] }
       giftConfigsByLevel: {},
       onShelfProducts: [],
+      categoryList: [],
       rules: {
         name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
         productType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
@@ -533,6 +544,32 @@ export default {
     isEdit() {
       return !!this.$route.query.id
     },
+    categoryGroups() {
+      const groups = []
+      const map = {}
+      const flat = []
+      const walk = (nodes, level) => {
+        for (const node of nodes) {
+          const prefix = level > 0 ? ' '.repeat(level) : ''
+          flat.push({ id: node.id, label: prefix + node.name, parentId: node.parentId, level })
+          map[node.id] = node
+          if (node.children && node.children.length) {
+            walk(node.children, level + 1)
+          }
+        }
+      }
+      walk(this.categoryList, 0)
+      for (const item of flat) {
+        const parentName = map[item.parentId] ? map[item.parentId].name : '顶级分类'
+        let group = groups.find(g => g.label === parentName)
+        if (!group) {
+          group = { label: parentName, options: [] }
+          groups.push(group)
+        }
+        group.options.push(item)
+      }
+      return groups
+    },
     isSupplierAdmin() {
       const rolesStr = localStorage.getItem('roles')
       const roles = rolesStr ? JSON.parse(rolesStr) : []
@@ -578,6 +615,7 @@ export default {
     this.loadPredefinedFields()
     this.ensureMembershipLevels()
     this.loadOnShelfProducts()
+    this.loadCategories()
     if (this.isEdit) {
       this.loadProduct(this.$route.query.id)
     }
@@ -645,7 +683,8 @@ export default {
             totalSessions: p.totalSessions || 1,
             validityDays: p.validityDays || 365,
             guideScope: p.guideScope || 'all',
-            sortOrder: p.sortOrder != null ? p.sortOrder : 0
+            sortOrder: p.sortOrder != null ? p.sortOrder : 0,
+            categoryId: p.categoryId || null
           }
           if (p.images) {
             try {
@@ -781,6 +820,16 @@ async loadGiftRule(productId) {
         console.warn('加载上架商品失败', e)
       }
     },
+    async loadCategories() {
+      try {
+        const res = await getCategoryTree(true)
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.categoryList = res.data
+        }
+      } catch (e) {
+        console.warn('加载商品分类失败', e)
+      }
+    },
     async loadGiftConfigs(productId) {
       try {
         await this.ensureMembershipLevels()

+ 39 - 4
cfc-web/src/views/admin/ProductManage.vue

@@ -4,6 +4,15 @@
       <div slot="header" class="admin-page-header">
         <span class="admin-page-title">商品管理</span>
         <el-button type="success" size="mini" icon="el-icon-plus" @click="$router.push('/product-edit')">新增商品</el-button>
+        <el-dropdown trigger="hover" @command="handleBatchCmd">
+          <el-button size="mini" type="primary">
+            批量<i class="el-icon-arrow-down el-icon--right"></i>
+          </el-button>
+          <el-dropdown-menu slot="dropdown">
+            <el-dropdown-item command="batchOnShelf">批量上架</el-dropdown-item>
+            <el-dropdown-item command="batchOffShelf">批量下架</el-dropdown-item>
+          </el-dropdown-menu>
+        </el-dropdown>
       </div>
 
       <div class="filter-bar" style="margin-bottom:12px;">
@@ -36,7 +45,8 @@
       </div>
 
       <div class="table-scroll-wrap-sm">
-        <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
+        <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="60" />
       <el-table-column prop="id" label="ID" width="70" />
       <el-table-column label="商品图片" width="80">
         <template slot-scope="{ row }">
@@ -115,7 +125,7 @@
             <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
 
           </template></el-table-column>
-      <el-table-column label="操作" width="200" fixed="right">
+      <el-table-column label="操作" width="280" fixed="right">
         <template slot-scope="{ row }">
           <el-button size="mini" type="primary" @click="$router.push('/product-edit?id=' + row.id)">编辑</el-button>
           <el-button size="mini" type="info" @click="showInventoryDialog(row)">库存</el-button>
@@ -225,7 +235,7 @@
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
-import { getProductList, reviewProduct, shelveProduct, copyProduct } from '@/api/admin'
+import { getProductList, reviewProduct, shelveProduct, batchShelveProduct, copyProduct } from '@/api/admin'
 
 export default {
   mixins: [SortMixin],
@@ -258,7 +268,8 @@ export default {
       detail: null,
       inventoryDialogVisible: false,
       inventoryLoading: false,
-      inventoryData: null
+      inventoryData: null,
+      selectedIds: []  // 批量操作选中的商品ID集合
     }
   },
   mounted() {
@@ -408,6 +419,30 @@ export default {
         case 'offShelf': this.handleShelve(row, false); break;
         case 'onShelf': this.handleShelve(row, true); break;
       }
+    },
+    handleBatchCmd(cmd) {
+      if (cmd === 'batchOnShelf') {
+        this.handleBatchShelve(true)
+      } else if (cmd === 'batchOffShelf') {
+        this.handleBatchShelve(false)
+      }
+    },
+    async handleBatchShelve(shelve) {
+      if (this.selectedIds.length === 0) {
+        return this.$message.warning('请先选择商品')
+      }
+      try {
+        await this.$confirm('确认' + (shelve ? '批量上架' : '批量下架') + '选中的 ' + this.selectedIds.length + ' 个商品?', '提示')
+        await batchShelveProduct(this.selectedIds, shelve)
+        this.$message.success(shelve ? '批量上架成功' : '批量下架成功')
+        this.selectedIds = []
+        this.loadList()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('操作失败')
+      }
+    },
+    handleSelectionChange(rows) {
+      this.selectedIds = rows.map(r => r.id)
     }
   }
 }

+ 1 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -374,7 +374,7 @@
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
 | `2026-09-09-team-hierarchy-design.md` | 🟡 设计稿(团队层级树从下往上生长 + 团队消费额分级统计 + 直推成员消费/CF明细 + 我的推荐人展示 + 去掉富页面推荐码) | 团队层级与推荐关系可视化 |
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
-| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则)) | 后台接口参考文档 |
+| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则);2026-10-05 补充商品批量上下架 `POST /api/admin/product/batch-shelve`(body: `{productIds, shelve}`,批量校验仅审核通过商品可操作)) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
 | `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |

+ 1 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -895,6 +895,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/admin/product/update` | 更新商品 |
 | `POST /api/admin/product/copy` | 复制商品 |
 | `POST /api/admin/product/delete` | 删除商品 |
+| `POST /api/admin/product/batch-shelve` | 批量上下架(body: `{productIds, shelve}`) |
 
 ### 4.26 管理端-成长档案关联商品(`/api/admin/growth-archive-product/*`)