Selaa lähdekoodia

feat: 饮食/文章管理完善 + 供需分发系统文档

Sisyphus 4 kuukautta sitten
vanhempi
sitoutus
ead28d1ebe

+ 6 - 3
cfc-backend/.classpath

@@ -36,18 +36,21 @@
 			<attribute name="maven.pomderived" value="true"/>
 		</attributes>
 	</classpathentry>
-	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
+	<classpathentry kind="src" path="target/generated-sources/annotations">
 		<attributes>
 			<attribute name="optional" value="true"/>
-			<attribute name="test" value="true"/>
 			<attribute name="maven.pomderived" value="true"/>
 			<attribute name="ignore_optional_problems" value="true"/>
 			<attribute name="m2e-apt" value="true"/>
 		</attributes>
 	</classpathentry>
-	<classpathentry kind="src" path="target/generated-sources/annotations">
+	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
 		<attributes>
 			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="ignore_optional_problems" value="true"/>
+			<attribute name="m2e-apt" value="true"/>
+			<attribute name="test" value="true"/>
 		</attributes>
 	</classpathentry>
 	<classpathentry kind="output" path="target/classes"/>

+ 0 - 29
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -6,7 +6,6 @@ import com.etotem.cfc.entity.ArticleCategory;
 import com.etotem.cfc.service.ArticleCategoryService;
 import com.etotem.cfc.service.ArticleService;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 import javax.annotation.Resource;
@@ -27,9 +26,6 @@ public class AdminArticleController {
     @Resource
     private ArticleCategoryService articleCategoryService;
 
-    @Resource
-    private JdbcTemplate jdbcTemplate;
-
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body) {
         String status = (String) body.get("status");
@@ -81,17 +77,6 @@ public class AdminArticleController {
         }
         articleService.create(article, adminId);
 
-        Object tagIds = body.get("tagIds");
-        if (tagIds instanceof List) {
-            List<?> tagIdList = (List<?>) tagIds;
-            for (Object tid : tagIdList) {
-                if (tid != null) {
-                    jdbcTemplate.update(
-                        "INSERT IGNORE INTO article_tags (article_id, tag_id) VALUES (?, ?)",
-                        article.getId(), Long.valueOf(tid.toString()));
-                }
-            }
-        }
         Map<String, Object> result = new HashMap<>();
         result.put("id", article.getId());
         return Result.success(result);
@@ -130,20 +115,6 @@ public class AdminArticleController {
         article.setArticleType((String) body.get("articleType"));
         articleService.update(article);
 
-        if (article.getId() != null) {
-            jdbcTemplate.update("DELETE FROM article_tags WHERE article_id = ?", article.getId());
-            Object tagIds = body.get("tagIds");
-            if (tagIds instanceof List) {
-                List<?> tagIdList = (List<?>) tagIds;
-                for (Object tid : tagIdList) {
-                    if (tid != null) {
-                        jdbcTemplate.update(
-                            "INSERT IGNORE INTO article_tags (article_id, tag_id) VALUES (?, ?)",
-                            article.getId(), Long.valueOf(tid.toString()));
-                    }
-                }
-            }
-        }
         return Result.success("更新成功");
     }
 

+ 60 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java

@@ -8,8 +8,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 @Tag(name = "\u7ba1\u7406\u7aef-\u98df\u6750\u7ba1\u7406")
 @RestController
@@ -21,16 +24,44 @@ public class AdminFoodController {
 
     @Operation(summary = "\u98df\u6750\u5217\u8868")
     @PostMapping("/list")
-    public Result<List<Food>> list(@RequestBody Map<String, Object> params) {
+    public Result<List<Map<String, Object>>> list(@RequestBody Map<String, Object> params) {
         String category = (String) params.get("category");
-        return Result.success(foodService.listAll(category, "active"));
+        List<Food> foods = foodService.listAll(category, "active");
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Food food : foods) {
+            Map<String, Object> item = new java.util.HashMap<>();
+            item.put("id", food.getId());
+            item.put("name", food.getName());
+            item.put("category", food.getCategory());
+            item.put("unit", food.getUnit());
+            item.put("calories", food.getCalories());
+            item.put("protein", food.getProtein());
+            item.put("fat", food.getFat());
+            item.put("carbs", food.getCarbs());
+            item.put("fiber", food.getFiber());
+            item.put("energyKj", food.getEnergyKj());
+            item.put("starch", food.getStarch());
+            item.put("cholesterol", food.getCholesterol());
+            item.put("priceLevel", food.getPriceLevel());
+            item.put("nutritionTags", food.getNutritionTags());
+            item.put("suitableFor", food.getSuitableFor());
+            item.put("allergens", food.getAllergens());
+            item.put("coverImage", food.getCoverImage());
+            item.put("status", food.getStatus());
+            item.put("sortOrder", food.getSortOrder());
+            item.put("createdAt", food.getCreatedAt());
+            item.put("updatedAt", food.getUpdatedAt());
+            item.put("months", foodService.getMonths(food.getId()));
+            result.add(item);
+        }
+        return Result.success(result);
     }
 
     @Operation(summary = "\u65b0\u589e\u98df\u6750")
     @PostMapping("/create")
-    public Result<String> create(@RequestBody Food food) {
+    public Result<Food> create(@RequestBody Food food) {
         foodService.create(food);
-        return Result.success("ok");
+        return Result.success(food);
     }
 
     @Operation(summary = "\u66f4\u65b0\u98df\u6750")
@@ -48,4 +79,29 @@ public class AdminFoodController {
         foodService.delete(id);
         return Result.success("ok");
     }
+
+    @Operation(summary = "\u98df\u6750\u8be6\u60c5\uff08\u542b\u5e94\u5b63\u6708\u4efd\uff09")
+    @PostMapping("/detail")
+    public Result<Map<String, Object>> detail(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id\u4e0d\u80fd\u4e3a\u7a7a");
+        Map<String, Object> result = foodService.getDetailWithMonths(id);
+        if (result == null) return Result.error("\u98df\u6750\u4e0d\u5b58\u5728");
+        return Result.success(result);
+    }
+
+    @Operation(summary = "\u4fdd\u5b58\u5e94\u5b63\u6708\u4efd")
+    @PostMapping("/saveMonths")
+    public Result<String> saveMonths(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id\u4e0d\u80fd\u4e3a\u7a7a");
+        @SuppressWarnings("unchecked")
+        List<Integer> months = params.get("months") != null
+                ? ((List<Object>) params.get("months")).stream()
+                    .map(o -> o instanceof Integer ? (Integer) o : Integer.valueOf(o.toString()))
+                    .collect(Collectors.toList())
+                : Collections.emptyList();
+        foodService.saveMonths(id, months);
+        return Result.success("ok");
+    }
 }

+ 123 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
@@ -71,6 +72,9 @@ public class ArticleService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
@@ -345,16 +349,45 @@ public class ArticleService {
             article.setPublishedAt(new Date());
         }
         articleMapper.insert(article);
+
+        // AI auto-tagging
+        try {
+            List<String> tagNames = autoGenerateTags(article.getContent());
+            if (!tagNames.isEmpty()) {
+                saveAutoTags(article.getId(), tagNames);
+            }
+        } catch (Exception e) {
+            log.warn("AI 自动标签生成失败(不影响文章创建): articleId={}, error={}",
+                article.getId(), e.getMessage());
+        }
     }
 
     @Transactional
     public void update(Article article) {
         // Recalculate wordCount if content changed
+        boolean contentChanged = false;
         if (article.getContent() != null) {
+            Article existing = articleMapper.selectById(article.getId());
+            String oldContent = existing != null ? existing.getContent() : null;
             article.setWordCount(calculateWordCount(article.getContent()));
+            contentChanged = oldContent == null || !oldContent.equals(article.getContent());
         }
         article.setUpdatedAt(new Date());
         articleMapper.updateById(article);
+
+        // AI auto-tagging only when content changed
+        if (contentChanged && article.getContent() != null) {
+            try {
+                jdbcTemplate.update("DELETE FROM article_tags WHERE article_id = ?", article.getId());
+                List<String> tagNames = autoGenerateTags(article.getContent());
+                if (!tagNames.isEmpty()) {
+                    saveAutoTags(article.getId(), tagNames);
+                }
+            } catch (Exception e) {
+                log.warn("AI 自动标签更新失败(不影响文章更新): articleId={}, error={}",
+                    article.getId(), e.getMessage());
+            }
+        }
     }
 
     @Transactional
@@ -651,4 +684,94 @@ public class ArticleService {
         article.setUpdatedAt(new Date());
         articleMapper.updateById(article);
     }
+
+    /**
+     * 调用 Dify AI 从文章内容中自动提取 3-5 个中文关键词作为标签
+     */
+    private List<String> autoGenerateTags(String content) {
+        if (content == null || content.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        // 去除 HTML 标签,避免干扰 AI 提取
+        String plainText = content.replaceAll("<[^>]*>", " ").replaceAll("\\s+", " ").trim();
+        if (plainText.isEmpty()) {
+            return Collections.emptyList();
+        }
+        // 截取前 1500 字,避免 prompt 过长
+        if (plainText.length() > 1500) {
+            plainText = plainText.substring(0, 1500);
+        }
+
+        String prompt = "你是一个家庭教育内容分析助手。请根据以下文章内容,提取 3 到 5 个最能代表文章主题的中文关键词作为标签。"
+            + "要求:1) 关键词为中文短语或名词,2-6 个字;2) 用英文逗号分隔;3) 不要包含编号、引号或解释文字;4) 只返回标签列表本身。"
+            + "\n\n文章内容:" + plainText;
+
+        try {
+            Map<String, Object> aiResult = aiService.sendMessage(prompt, "0", "", null);
+            String aiResponse = (String) aiResult.get("answer");
+            if (aiResponse == null || aiResponse.trim().isEmpty()) {
+                return Collections.emptyList();
+            }
+            // 解析逗号分隔的标签
+            List<String> tags = new ArrayList<>();
+            for (String raw : aiResponse.split(",")) {
+                String tag = raw.trim()
+                        .replaceAll("^[\\d\\.\\-\\s、。,]+", "")
+                        .replaceAll("[\\s、。,]+$", "")
+                        .trim();
+                if (!tag.isEmpty() && tag.length() <= 20 && tags.size() < 5) {
+                    tags.add(tag);
+                }
+            }
+            return tags;
+        } catch (Exception e) {
+            log.warn("AI 自动生成标签失败:{}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 将 AI 生成的标签保存到 dan_knowledge_tag 与 article_tags 表,并回写 article.tags 字段
+     */
+    private void saveAutoTags(Long articleId, List<String> tagNames) {
+        if (articleId == null || tagNames == null || tagNames.isEmpty()) {
+            return;
+        }
+        List<String> savedTags = new ArrayList<>();
+        for (String tagName : tagNames) {
+            if (tagName == null || tagName.trim().isEmpty()) continue;
+            String name = tagName.trim();
+            try {
+                // 查询是否已存在同名标签
+                List<Long> existing = jdbcTemplate.queryForList(
+                        "SELECT id FROM dan_knowledge_tag WHERE tag_name = ? LIMIT 1",
+                        Long.class, name);
+                Long tagId;
+                if (existing != null && !existing.isEmpty()) {
+                    tagId = existing.get(0);
+                } else {
+                    // 插入新标签
+                    jdbcTemplate.update(
+                            "INSERT INTO dan_knowledge_tag (tag_name, tag_type, created_at) VALUES (?, 'auto', NOW())",
+                            name);
+                    tagId = jdbcTemplate.queryForObject(
+                            "SELECT id FROM dan_knowledge_tag WHERE tag_name = ? ORDER BY id DESC LIMIT 1",
+                            Long.class, name);
+                }
+                if (tagId != null) {
+                    jdbcTemplate.update(
+                            "INSERT IGNORE INTO article_tags (article_id, tag_id) VALUES (?, ?)",
+                            articleId, tagId);
+                    savedTags.add(name);
+                }
+            } catch (Exception e) {
+                log.warn("保存自动标签失败:tagName={}, error={}", name, e.getMessage());
+            }
+        }
+        // 回写 article.tags 字段(逗号分隔)
+        if (!savedTags.isEmpty()) {
+            String tagsStr = String.join(",", savedTags);
+            jdbcTemplate.update("UPDATE articles SET tags = ? WHERE id = ?", tagsStr, articleId);
+        }
+    }
 }

+ 51 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java

@@ -1,13 +1,18 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.SeasonalFood;
 import com.etotem.cfc.mapper.FoodMapper;
+import com.etotem.cfc.mapper.SeasonalFoodMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
-import java.util.List;
+import java.util.*;
+import java.util.stream.Collectors;
 
 @Slf4j
 @Service
@@ -16,6 +21,9 @@ public class FoodService {
     @Resource
     private FoodMapper foodMapper;
 
+    @Resource
+    private SeasonalFoodMapper seasonalFoodMapper;
+
     public List<Food> listAll(String category, String status) {
         LambdaQueryWrapper<Food> wrapper = new LambdaQueryWrapper<Food>()
                 .eq(status != null, Food::getStatus, status)
@@ -62,6 +70,48 @@ public class FoodService {
     /**
      * 批量保存食材推荐(菌群报告食材推荐数据)
      */
+    public Map<String, Object> getDetailWithMonths(Long id) {
+        Food food = foodMapper.selectById(id);
+        if (food == null) return null;
+        List<SeasonalFood> seasonal = seasonalFoodMapper.selectList(
+                new LambdaQueryWrapper<SeasonalFood>()
+                        .eq(SeasonalFood::getFoodId, id));
+        List<Integer> months = seasonal.stream()
+                .map(SeasonalFood::getMonth)
+                .sorted()
+                .collect(Collectors.toList());
+        Map<String, Object> result = new HashMap<>();
+        result.put("food", food);
+        result.put("months", months);
+        return result;
+    }
+
+    @Transactional
+    public void saveMonths(Long foodId, List<Integer> months) {
+        seasonalFoodMapper.delete(
+                new LambdaQueryWrapper<SeasonalFood>()
+                        .eq(SeasonalFood::getFoodId, foodId));
+        if (months != null && !months.isEmpty()) {
+            for (Integer month : months) {
+                SeasonalFood sf = new SeasonalFood();
+                sf.setFoodId(foodId);
+                sf.setMonth(month);
+                sf.setRegion("");
+                sf.setPeakFlag(0);
+                sf.setSortOrder(0);
+                sf.setCreatedAt(new Date());
+                seasonalFoodMapper.insert(sf);
+            }
+        }
+    }
+
+    public List<Integer> getMonths(Long foodId) {
+        List<SeasonalFood> list = seasonalFoodMapper.selectList(
+                new LambdaQueryWrapper<SeasonalFood>()
+                        .eq(SeasonalFood::getFoodId, foodId));
+        return list.stream().map(SeasonalFood::getMonth).sorted().collect(Collectors.toList());
+    }
+
     public void saveFoodsForReport(Long reportId, List<Food> foods) {
         if (foods == null || foods.isEmpty()) return;
         int sortOrder = 0;

+ 74 - 0
cfc-backend/src/main/resources/db/migration/supply_system_schema.sql

@@ -0,0 +1,74 @@
+-- ============================================================
+-- 供应商分销体系 — 数据库迁移
+-- 4 张新表 + 1 张扩展现有表
+-- ============================================================
+
+-- 1. 供应商体系
+CREATE TABLE IF NOT EXISTS `supply_system` (
+  `id`          bigint       NOT NULL AUTO_INCREMENT,
+  `name`        varchar(100) NOT NULL COMMENT '体系名称',
+  `admin_id`    bigint       NOT NULL COMMENT '管理员用户ID',
+  `settlement_period_days` int NOT NULL DEFAULT 30 COMMENT '账期(天)',
+  `platform_profit_rate` decimal(5,2) NOT NULL DEFAULT 0 COMMENT '平台留利比例(%)',
+  `description` varchar(500) DEFAULT NULL COMMENT '描述',
+  `status`      varchar(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled',
+  `created_at`  datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at`  datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_admin_id` (`admin_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系';
+
+-- 2. 体系成员
+CREATE TABLE IF NOT EXISTS `supply_system_member` (
+  `id`         bigint       NOT NULL AUTO_INCREMENT,
+  `system_id`  bigint       NOT NULL COMMENT '体系ID',
+  `user_id`    bigint       NOT NULL COMMENT '用户ID',
+  `role`       varchar(20)  NOT NULL DEFAULT 'member' COMMENT '角色: admin/member',
+  `status`     varchar(20)  NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive',
+  `joined_at`  datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_system_user` (`system_id`, `user_id`),
+  KEY `idx_user_id` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体系成员';
+
+-- 3. 结算记录
+CREATE TABLE IF NOT EXISTS `supply_settlement` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `system_id`       bigint       NOT NULL COMMENT '体系ID',
+  `period_start`    date         NOT NULL COMMENT '结算周期开始',
+  `period_end`      date         NOT NULL COMMENT '结算周期结束',
+  `total_sales`     int          NOT NULL DEFAULT 0 COMMENT '销售总额(分)',
+  `platform_profit` int          NOT NULL DEFAULT 0 COMMENT '平台留利金额(分)',
+  `supplier_payout` int          NOT NULL DEFAULT 0 COMMENT '供应商实付(分)',
+  `status`          varchar(20)  NOT NULL DEFAULT 'pending' COMMENT '状态: pending/settled',
+  `settled_at`      datetime     DEFAULT NULL COMMENT '结算时间',
+  `remark`          varchar(500) DEFAULT NULL COMMENT '备注',
+  `created_at`      datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_system_id` (`system_id`),
+  KEY `idx_period` (`period_start`, `period_end`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算记录';
+
+-- 4. 结算明细
+CREATE TABLE IF NOT EXISTS `supply_settlement_detail` (
+  `id`            bigint       NOT NULL AUTO_INCREMENT,
+  `settlement_id` bigint       NOT NULL COMMENT '结算ID',
+  `order_id`      bigint       NOT NULL COMMENT '订单ID',
+  `order_no`      varchar(64)  NOT NULL COMMENT '订单号',
+  `product_name`  varchar(200) NOT NULL COMMENT '商品名称',
+  `amount`        int          NOT NULL COMMENT '订单金额(分)',
+  `platform_fee`  int          NOT NULL COMMENT '平台留利(分)',
+  `supplier_id`   bigint       DEFAULT NULL COMMENT '收款供应商用户ID',
+  `supplier_name` varchar(100) DEFAULT NULL COMMENT '收款供应商名称',
+  `payout`        int          NOT NULL COMMENT '供应商实付(分)',
+  `created_at`    datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_settlement_id` (`settlement_id`),
+  KEY `idx_order_id` (`order_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算明细';
+
+-- 5. product_orders 扩展字段
+ALTER TABLE `product_orders`
+  ADD COLUMN `supply_system_id` bigint DEFAULT NULL COMMENT '所属供应商体系ID' AFTER `coupon_id`,
+  ADD COLUMN `supplier_user_id` bigint DEFAULT NULL COMMENT '收款供应商用户ID' AFTER `supply_system_id`,
+  ADD KEY `idx_supply_system_id` (`supply_system_id`);

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

@@ -918,3 +918,53 @@ export function resolveEmotionAlert(alertId) {
     data: { alertId, source: 'checkin' }
   })
 }
+
+// ========== 食材管理 API ==========
+
+export function getFoodList(params) {
+  return request({
+    url: '/api/admin/foods/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function createFood(data) {
+  return request({
+    url: '/api/admin/foods/create',
+    method: 'post',
+    data
+  })
+}
+
+export function updateFood(data) {
+  return request({
+    url: '/api/admin/foods/update',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteFood(id) {
+  return request({
+    url: '/api/admin/foods/delete',
+    method: 'post',
+    data: { id }
+  })
+}
+
+export function getFoodDetail(id) {
+  return request({
+    url: '/api/admin/foods/detail',
+    method: 'post',
+    data: { id }
+  })
+}
+
+export function saveFoodMonths(id, months) {
+  return request({
+    url: '/api/admin/foods/saveMonths',
+    method: 'post',
+    data: { id, months }
+  })
+}

+ 42 - 7
cfc-web/src/views/admin/Foods.vue

@@ -37,6 +37,12 @@
         <el-table-column prop="energyKj" label="能量(kJ)" width="100" />
         <el-table-column prop="starch" label="淀粉(g)" width="100" />
         <el-table-column prop="cholesterol" label="胆固醇(mg)" width="100" />
+        <el-table-column label="应季月份" width="180">
+          <template slot-scope="{ row }">
+            <span v-if="row.months && row.months.length">{{ row.months.map(function(m) { return m + '月' }).join('、') }}</span>
+            <span v-else style="color:#999">未设置</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="status" label="状态" width="80" />
         <el-table-column label="操作" width="160">
           <template slot-scope="{ row }">
@@ -93,6 +99,11 @@
         <el-form-item label="单位">
           <el-input v-model="form.unit" placeholder="如: 克、个、份" />
         </el-form-item>
+        <el-form-item label="应季月份">
+          <el-checkbox-group v-model="form.months">
+            <el-checkbox v-for="m in 12" :key="m" :label="m">{{ m }}月</el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
         <el-form-item label="状态">
           <el-select v-model="form.status" style="width:100%">
             <el-option label="启用" value="active" />
@@ -109,6 +120,8 @@
 </template>
 
 <script>
+import { getFoodDetail, saveFoodMonths } from '@/api/admin'
+
 export default {
   name: 'Foods',
   data() {
@@ -130,7 +143,8 @@ export default {
         starch: 0,
         cholesterol: 0,
         unit: '克',
-        status: 'active'
+        status: 'active',
+        months: []
       }
     }
   },
@@ -156,10 +170,10 @@ export default {
     },
     openCreate() {
       this.editId = null
-      this.form = { name: '', category: '', calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, energyKj: 0, starch: 0, cholesterol: 0, unit: '克', status: 'active' }
+      this.form = { name: '', category: '', calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, energyKj: 0, starch: 0, cholesterol: 0, unit: '克', status: 'active', months: [] }
       this.dialogVisible = true
     },
-    editItem(row) {
+    async editItem(row) {
       this.editId = row.id
       this.form = {
         name: row.name || '',
@@ -173,7 +187,16 @@ export default {
         starch: row.starch || 0,
         cholesterol: row.cholesterol || 0,
         unit: row.unit || '克',
-        status: row.status || 'active'
+        status: row.status || 'active',
+        months: []
+      }
+      try {
+        const res = await getFoodDetail(row.id)
+        if (res.data && res.data.months) {
+          this.form.months = res.data.months
+        }
+      } catch (e) {
+        // months stay empty
       }
       this.dialogVisible = true
     },
@@ -183,18 +206,30 @@ export default {
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
         const data = { ...this.form }
-        if (this.editId) {
-          data.id = this.editId
+        const months = data.months
+        delete data.months
+        let foodId = this.editId
+        if (foodId) {
+          data.id = foodId
           await axios.post(baseURL + '/api/admin/foods/update', data, {
             headers: { Authorization: 'Bearer ' + token }
           })
           this.$message && this.$message.success && this.$message.success('更新成功')
         } else {
-          await axios.post(baseURL + '/api/admin/foods/create', data, {
+          const res = await axios.post(baseURL + '/api/admin/foods/create', data, {
             headers: { Authorization: 'Bearer ' + token }
           })
+          foodId = res.data && res.data.data && res.data.data.id
           this.$message && this.$message.success && this.$message.success('创建成功')
         }
+        // Save seasonal months
+        if (foodId && months && months.length > 0) {
+          try {
+            await saveFoodMonths(foodId, months)
+          } catch (e) {
+            this.$message && this.$message.warning && this.$message.warning('应季月份保存失败')
+          }
+        }
         this.dialogVisible = false
         this.loadList()
       } catch (e) {

+ 278 - 0
docs/superpowers/plans/2026-07-05-supply-distribution-system.md

@@ -0,0 +1,278 @@
+# 供应商分销体系 — 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal**: 在管理后台建立供应商分销体系,支持多体系管理、成员、商品关联、结算
+
+**Architecture**: 4 张新表 (`supply_system`, `supply_system_member`, `supply_settlement`, `supply_settlement_detail`) + 扩展现有 `product_orders` 表。后端 Spring Boot 2.7.18 新增 4 Entity + 4 Mapper + 2 Service + 2 Controller。前端 Vue 2 + Element UI 新增 3 页面 + 修改 3 个现有页面。
+
+**Tech Stack**: Spring Boot 2.7.18 / MyBatis-Plus / Vue 2 / Element UI
+
+---
+
+## File Structure
+
+### 后端新增
+
+| 文件 | 职责 |
+|------|------|
+| `entity/SupplySystem.java` | 供应商体系实体 |
+| `entity/SupplySystemMember.java` | 体系成员实体 |
+| `entity/SupplySettlement.java` | 结算记录实体 |
+| `entity/SupplySettlementDetail.java` | 结算明细实体 |
+| `mapper/SupplySystemMapper.java` | 体系 Mapper |
+| `mapper/SupplySystemMemberMapper.java` | 成员 Mapper |
+| `mapper/SupplySettlementMapper.java` | 结算 Mapper |
+| `mapper/SupplySettlementDetailMapper.java` | 结算明细 Mapper |
+| `service/SupplySystemService.java` | 体系 + 成员业务逻辑 |
+| `service/SupplySettlementService.java` | 结算业务逻辑 |
+| `controller/admin/SupplySystemController.java` | 体系/成员 API |
+| `controller/admin/SupplySettlementController.java` | 结算 API |
+
+### 后端修改
+
+| 文件 | 修改内容 |
+|------|---------|
+| `entity/ProductOrder.java` | 加 `supplySystemId`, `supplierUserId` 字段 |
+| `entity/Product.java` | 已有 `distributionSystemId`, `profitRate` — 确认可用 |
+
+### 前端新增
+
+| 文件 | 职责 |
+|------|------|
+| `src/views/admin/supply-system/List.vue` | 体系列表 |
+| `src/views/admin/supply-system/Form.vue` | 新建/编辑体系 |
+| `src/views/admin/supply-system/Detail.vue` | 体系详情(4 Tab) |
+
+### 前端修改
+
+| 文件 | 修改内容 |
+|------|---------|
+| `src/api/admin.js` | 新增 supply-system CRUD API |
+| `src/views/admin/ProductEdit.vue` | 增加「所属供应商体系」下拉框 |
+| `src/views/admin/ProductEdit.vue` | 选择体系后自动填充 profit_rate |
+| `src/views/Layout.vue` | 侧边栏新增「供应商体系」菜单 |
+| `src/router/index.js` | 新增 supply-system 路由 |
+
+---
+
+## Tasks
+
+### Task 1: 数据库迁移 SQL
+
+**文件**: 在 `cfc-backend/src/main/resources/` 下创建 `supply_system_schema.sql`
+
+- [ ] 编写 4 张 CREATE TABLE 语句(见设计文档第 2.1 节)
+- [ ] 编写 ALTER TABLE `product_orders` 语句(加两个字段 + 索引)
+
+---
+
+### Task 2: 后端 Entity + Mapper(4 组)
+
+**文件**:
+- Create: `entity/SupplySystem.java`, `entity/SupplySystemMember.java`
+- Create: `entity/SupplySettlement.java`, `entity/SupplySettlementDetail.java`
+- Create: `mapper/SupplySystemMapper.java`, `mapper/SupplySystemMemberMapper.java`
+- Create: `mapper/SupplySettlementMapper.java`, `mapper/SupplySettlementDetailMapper.java`
+
+**Entity 模板** (以 SupplySystem 为例,使用 `@Data` + `@TableName` + `@TableId(type = IdType.AUTO)`,实现 Serializable):
+
+```java
+@Data
+@TableName("supply_system")
+public class SupplySystem implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private Long adminId;
+    private Integer settlementPeriodDays;
+    private BigDecimal platformProfitRate;  // 百分比,如 5.00
+    private String description;
+    private String status;       // active/disabled
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+- [ ] 创建 SupplySystem.java
+- [ ] 创建 SupplySystemMember.java(字段: id/systemId/userId/role/status/joinedAt)
+- [ ] 创建 SupplySettlement.java(字段见设计文档 2.1)
+- [ ] 创建 SupplySettlementDetail.java(字段见设计文档 2.1)
+- [ ] 创建 4 个 Mapper(每个 `extends BaseMapper<Entity>`,加 `@Mapper`)
+- [ ] **编译**: `cd cfc-backend && mvn clean compile` → 通过
+
+---
+
+### Task 3: 扩展 ProductOrder Entity
+
+- [ ] 在 `ProductOrder.java` 中新增:
+```java
+private Long supplySystemId;
+private Long supplierUserId;
+```
+- [ ] 确认 `Product.java` 已有 `distributionSystemId` 和 `profitRate` 字段
+- [ ] **编译通过**
+
+---
+
+### Task 4: SupplySystemService — 体系 + 成员业务逻辑
+
+**文件**: Create `service/SupplySystemService.java`
+
+**方法**:
+- `list(params)` — 分页查询(支持 keyword 模糊搜索 name,status 过滤)
+- `detail(id)` — 按 ID 查询
+- `create(params)` — 创建体系,创建者自动成为 admin 成员
+- `update(params)` — 更新体系
+- `toggleStatus(id)` — 切换 active/disabled
+- `listAll()` — 查询所有 active 体系(供前端下拉框)
+- `memberList(systemId)` — 查询成员列表(联表 User 取 nickname/phone)
+- `addMember(systemId, userId)` — 添加成员(role=member)
+- `removeMember(memberId)` — 移除成员
+- `changeRole(memberId, role)` — 变更角色
+
+**DI**: `@Resource SupplySystemMapper`, `SupplySystemMemberMapper`, `UserMapper`
+
+- [ ] 创建 Service,实现所有方法
+- [ ] **编译通过**
+
+---
+
+### Task 5: SupplySettlementService — 结算业务逻辑
+
+**文件**: Create `service/SupplySettlementService.java`
+
+**方法**:
+- `list(params)` — 分页查询(支持 systemId, status 过滤)
+- `detail(id)` — 按 ID 查询
+- `detailItems(settlementId)` — 查询结算明细列表
+- `createSettlement(params)` — 创建结算单
+- `confirm(id)` — 确认结算(pending → settled)
+
+- [ ] 创建 Service
+- [ ] **编译通过**
+
+---
+
+### Task 6: SupplySystemController — API 控制器
+
+**文件**: Create `controller/admin/SupplySystemController.java`
+
+路由 `/api/admin/supply-system`,提供端点:
+
+| 端点 | 方法 |
+|------|------|
+| `/list` | list |
+| `/create` | create |
+| `/update` | update |
+| `/detail` | detail |
+| `/toggle-status` | toggleStatus |
+| `/list-all` | listAll |
+| `/member/list` | memberList |
+| `/member/add` | addMember |
+| `/member/remove` | removeMember |
+| `/member/change-role` | changeRole |
+
+- [ ] 创建 Controller
+- [ ] **编译通过**
+
+---
+
+### Task 7: SupplySettlementController — 结算 API
+
+**文件**: Create `controller/admin/SupplySettlementController.java`
+
+路由 `/api/admin/supply-settlement`,提供端点:
+
+| 端点 | 方法 |
+|------|------|
+| `/list` | list |
+| `/detail` | detail |
+| `/detail-items` | detailItems |
+| `/create` | createSettlement |
+| `/confirm` | confirm |
+
+- [ ] 创建 Controller
+- [ ] **编译通过**
+
+---
+
+### Task 8: 前端 API 层
+
+**文件**: Modify `cfc-web/src/api/admin.js`
+
+在 `商品管理 API` 之前添加 15 个 API 函数(对应后端所有端点)。
+
+关键函数:
+- `getSupplySystemList`, `createSupplySystem`, `updateSupplySystem`
+- `getSupplySystemDetail`, `toggleSupplySystemStatus`
+- `listAllSupplySystems` — 供 ProductEdit 下拉框使用
+- `getSupplySystemMembers`, `addSupplySystemMember`, `removeSupplySystemMember`, `changeSupplySystemMemberRole`
+- `getSupplySettlementList`, `getSupplySettlementDetail`, `getSupplySettlementDetailItems`
+- `createSupplySettlement`, `confirmSupplySettlement`
+
+- [ ] 在 admin.js 末尾添加 15 个 API 导出函数
+
+---
+
+### Task 9: 前端 — 供应商体系列表页 List.vue
+
+**文件**: Create `src/views/admin/supply-system/List.vue`
+
+- [ ] 搜索栏:按名称搜索
+- [ ] el-table:名称/账期/平台留利/状态/操作(详情/编辑/启用禁用)
+- [ ] 新建按钮 → /supply-system/create
+- [ ] 分页
+- [ ] 搜索防抖 + 加载状态
+
+---
+
+### Task 10: 前端 — 供应商体系表单页 Form.vue
+
+**文件**: Create `src/views/admin/supply-system/Form.vue`
+
+- [ ] 新建模式 (`/supply-system/create`) vs 编辑模式 (`/supply-system/:id/edit`)
+- [ ] 表单字段:名称/管理员(远程搜索用户)/账期天数/平台留利%/描述
+- [ ] 表单验证 (名称/管理员必填)
+- [ ] 提交后跳转列表
+
+---
+
+### Task 11: 前端 — 体系详情页 Detail.vue
+
+**文件**: Create `src/views/admin/supply-system/Detail.vue`
+
+4 个 el-tab-pane:
+- [ ] **商品** Tab:按 `distribution_system_id` 查询商品列表
+- [ ] **成员** Tab:成员表格 + 添加/移除/改角色
+- [ ] **订单** Tab:订单列表,每行可选收款方(下拉框绑定体系成员)
+- [ ] **结算** Tab:结算记录 + 生成结算单 + 确认结算
+
+---
+
+### Task 12: 路由 + 菜单 + ProductEdit 修改
+
+- [ ] **router/index.js**:新增 4 个路由(List/Create/Detail/Edit)
+- [ ] **Layout.vue**:侧边栏新增 `{ path: '/supply-system', label: '供应商体系', icon: 'el-icon-s-management', perm: 'supply:system' }`
+- [ ] **ProductEdit.vue**:增加「所属体系」下拉选择器,选择后自动填充 profit_rate(% × 10 = ‰)
+
+---
+
+### Task 13: 编译验证
+
+- [ ] **后端**:`cd cfc-backend && mvn clean compile` — 0 error
+- [ ] **前端**:`cd cfc-web && npm run build` — Build complete
+
+---
+
+## Self-review 核对
+
+- [ ] 所有设计文档的 API 端点 → 有对应的 Controller 方法
+- [ ] 所有 Controller 方法 → 有对应的 Service 方法
+- [ ] 所有前端页面 → 有对应的后端 API
+- [ ] 所有新建 Entity → 有对应的 CREATE TABLE SQL
+- [ ] ProductOrder 扩展字段 → 有 ALTER TABLE SQL
+- [ ] 路由配置包含所有 supply-system 路径
+- [ ] Layout 菜单引用正确路径(/supply-system)
+```
+

+ 326 - 0
docs/superpowers/plans/2026-07-06-test-fix-and-enhance-plan.md

@@ -0,0 +1,326 @@
+# API Issue Fix + Test Enhancement Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Fix remaining API issues from v10 test report, update API test coverage for newly added features, and document E2E test deployment path.
+
+**Architecture:** Backend fixes in existing controllers (ProductSkuController, AdminGuideController) targeting NPE and 500 errors. Add API test cases for recent features to v9_test.js. Document uni-app H5 deployment config for E2E test execution.
+
+**Tech Stack:** Spring Boot 2.7.18 (backend), Node.js (API tests), Playwright (E2E tests)
+
+---
+
+## File Changes Map
+
+| File | Action | Reason |
+|------|--------|--------|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ProductSkuController.java` | Modify | Fix NPE in listByProduct (NEW-02) |
+| `cfc-backend/.../controller/guide/AdminGuideController.java` | Modify | Fix 500 in activity list (INFO-GA) |
+| `tests/v9_test.js` | Modify | Add test cases for food/seasonal-months/nutrition endpoints |
+| `tests/ISSUE-TRACKING.md` | Modify | Update issue statuses |
+| `tests/TEST-RESULTS-v10.md` | Modify | Add v10.5 results |
+| `cfc-backend/src/main/resources/application.yml` | Check | Verify guide activity test config |
+
+---
+
+### Task 1: Commit Current Uncommitted Changes
+
+**Files:**
+- Commit: `cfc-backend/.../controller/admin/AdminArticleController.java`
+- Commit: `cfc-backend/.../controller/admin/AdminFoodController.java`
+- Commit: `cfc-backend/.../service/ArticleService.java`
+- Commit: `cfc-backend/.../service/FoodService.java`
+- Commit: `cfc-web/src/api/admin.js`
+- Commit: `cfc-web/src/views/admin/Foods.vue`
+
+- [ ] **Step 1: Stage and commit the current session's work**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
+git add cfc-web/src/api/admin.js
+git add cfc-web/src/views/admin/Foods.vue
+git commit -m "feat: article AI auto-tagging + food seasonal months management"
+```
+
+Expected: 7 files committed.
+
+---
+
+### Task 2: Fix SKU List NPE (NEW-02)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ProductSkuController.java`
+
+**Background:** `POST /api/admin/product/sku/list` crashes with 500 when `productIdObj` is null. The `params.get("productId")` returns null, then `.toString()` on null throws NPE before the guard.
+
+- [ ] **Step 1: Read and fix ProductSkuController.listByProduct()**
+
+Read the file:
+```
+cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ProductSkuController.java
+```
+
+Locate the `listByProduct` method. Find the problematic code:
+```java
+Object productIdObj = params.get("productId");
+if (productIdObj == null) return Result.error("productId不能为空");
+Long productId = Long.parseLong(productIdObj.toString());  // NPE if null
+```
+
+Fix: The issue is that `Long.parseLong(productIdObj.toString())` can NPE because `productIdObj` is checked for null, but `toString()` could be called on a differently typed null. Actually the real NPE is likely that `productIdObj` is not null but is an Integer (not Long), so `Long.parseLong()` fails on something else. But the guard seems correct. Let me re-read the issue:
+
+From NEW-ISSUES-20260702.md:
+```java
+Object productIdObj = params.get("productId");
+if (productIdObj == null) return Result.error("productId不能为空");
+Long productId = Long.parseLong(productIdObj.toString());
+```
+
+Wait, this actually looks fine for NPE (productIdObj is checked before calling toString). The issue might be that `Long.parseLong` fails because `productIdObj` might be an Integer, and Integer.toString() returns the number as a string, so `Long.parseLong("123")` works fine.
+
+Actually, let me re-read the v10 test result: "SKU list | ✅ PASS | SKU列表查询正常(NPE已修复)" - NEW-02 shows it's already fixed in the test! Let me check the git log for this fix.
+
+Wait, looking at the test results again:
+```
+| NEW-02 | SKU list | ✅ PASS | SKU列表查询正常(NPE已修复) |
+```
+
+But in NEW-ISSUES-20260702.md:
+```
+## NEW-02:SKU列表接口返回 500 ❌ 仍有Bug
+```
+
+The test result says PASS but the issue doc says FAIL. The test was from v10 (2026-07-04) and the issue doc is from 2026-07-02. So the fix was likely applied between those dates.
+
+Let me check if there's a recent fix for this.
+
+- [ ] **Step 2: Verify the fix exists**
+
+Search for the listByProduct method:
+```
+grep -n "listByProduct" cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ProductSkuController.java
+```
+
+Read the method to confirm the fix is already in place. If `Long.parseLong` is used with a try-catch or the input validation uses instanceof, the fix is done.
+
+If the fix IS already in place:
+- Update NEW-ISSUES-20260702.md to mark NEW-02 as fixed
+- Update ISSUE-TRACKING.md to move NEW-02 to "Closed"
+
+If the fix is NOT in place:
+- Apply the fix:
+```java
+Object productIdObj = params.get("productId");
+if (productIdObj == null) return Result.error("productId不能为空");
+Long productId;
+try {
+    productId = Long.parseLong(productIdObj.toString());
+} catch (NumberFormatException e) {
+    return Result.error("productId格式错误");
+}
+```
+
+---
+
+### Task 3: Investigate Guide Activity List 500 (INFO-GA)
+
+**Files:**
+- Read: `cfc-backend/.../controller/guide/AdminGuideController.java` (or wherever the guide activities list endpoint lives)
+
+**Background:** `POST /api/guide/activities/list` returns 500. Test report marks it as INFO-GA ("可能是数据问题"). This might be an endpoint that requires a specific data state.
+
+- [ ] **Step 1: Find the guide activities list endpoint**
+
+Search:
+```
+grep -rn "activities/list" cfc-backend/src/main/java/com/etotem/cfc/controller/
+```
+
+Read the controller method handling this endpoint to determine if it's a code bug or a data issue.
+
+- [ ] **Step 2: Analyze the root cause**
+
+Check if the method:
+1. Requires a non-null parameter that may be missing
+2. Has a null pointer or SQL exception
+3. Depends on specific data (guide has activities, etc.)
+4. Has missing @PostMapping (might be @GetMapping)
+
+- [ ] **Step 3: Fix or document**
+
+If code bug: Apply minimal fix (add null checks, try-catch, or default values).
+
+If data issue: Update ISSUE-TRACKING.md to note it's a data dependency, not a code bug.
+
+---
+
+### Task 4: Investigate Vendor Login Failure (ISSUE-001)
+
+**Files:**
+- Read: `cfc-backend/.../controller/auth/AuthController.java`
+- Read: `cfc-backend/.../config/JwtInterceptor.java`
+
+**Background:** Vendor login (`POST /api/auth/phone-login`) returns non-token response. The test vendor (userId=81069) has `vendor_status=pending` in the test data.
+
+- [ ] **Step 1: Read AuthController.phoneLogin()**
+
+Check if the phone login method has any role-based filtering that might block pending vendors.
+
+- [ ] **Step 2: Read JwtInterceptor**
+
+Check if there are any JWT rules that might affect vendor token generation.
+
+- [ ] **Step 3: Test login manually**
+
+Try:
+```bash
+curl -X POST http://cfc.iwintrue.com/api/auth/phone-login \
+  -H "Content-Type: application/json" \
+  -d '{"phone":"13800138001","code":"123456"}'
+```
+
+Check the response. If it returns a proper token, the issue is the test data (vendor not approved). If it returns an error, it's a code bug.
+
+- [ ] **Step 4: Fix or document**
+
+If vendor_status=pending is blocking login → Mark as test data issue, document in ISSUE-TRACKING.md.
+
+If code bug → Fix the login flow to allow pending vendors to log in (they just can't use vendor-only features until approved).
+
+---
+
+### Task 5: Add API Test Cases for New Features
+
+**Files:**
+- Modify: `tests/v9_test.js`
+
+**Background:** The current API test script doesn't cover:
+1. Food management CRUD (foods list/create/update/delete with seasonal months)
+2. Article AI auto-tagging (just verify article create still works and returns tags)
+3. Food recommendation endpoints (food-recommend-index, food-recommend-history)
+4. The new seasonal months endpoints (food detail, saveMonths)
+
+- [ ] **Step 1: Read existing v9_test.js structure**
+
+```
+less tests/v9_test.js
+```
+
+Understand the test structure: how tests are grouped, how tokens are obtained, how results are reported.
+
+- [ ] **Step 2: Add food management test group**
+
+Add tests for:
+```
+NEW-FOOD-01: Food list (list all foods)
+NEW-FOOD-02: Food create (create a new food item)
+NEW-FOOD-03: Food detail with months (verify months field present)
+NEW-FOOD-04: Save seasonal months (set months 6,7,8 for a food)
+NEW-FOOD-05: Food delete (cleanup)
+```
+
+Each test should use the same admin token mechanism as existing tests.
+
+- [ ] **Step 3: Add food recommendation test group (if endpoints exist)**
+
+Check if these endpoints exist:
+```
+POST /api/health/foods/by-family
+POST /api/meal/recommend
+```
+
+If they exist, add basic health-check tests.
+
+- [ ] **Step 4: Run the updated test script**
+
+```bash
+node tests/v9_test.js 2>&1 | tee tests/v10.5_result.txt
+```
+
+Verify all existing tests still PASS and new tests PASS.
+
+---
+
+### Task 6: Update Test Results and Issue Tracking
+
+**Files:**
+- Modify: `tests/TEST-RESULTS-v10.md` (or create v11)
+- Modify: `tests/ISSUE-TRACKING.md`
+- Modify: `docs/系统测试/issue-tracking/NEW-ISSUES-20260702.md`
+
+- [ ] **Step 1: Update test results**
+
+Create `tests/TEST-RESULTS-v11.md` with the new test run results. Include:
+- Version info (date, commit hash)
+- Test summary (PASS/FAIL/SKIP counts)
+- New feature verification results
+- Regression test results
+
+- [ ] **Step 2: Update issue tracking**
+
+In `tests/ISSUE-TRACKING.md`:
+- If SKU list NPE is fixed → move NEW-02 to "Closed"
+- If vendor login is data issue → update status to "Test data needed"
+- If INFO-GA is data issue → update note
+
+In `docs/系统测试/issue-tracking/NEW-ISSUES-20260702.md`:
+- Mark NEW-02 as fixed/verified if applicable
+
+---
+
+### Task 7: E2E Test Environment Documentation
+
+**Files:**
+- Create: `docs/E2E-DEPLOYMENT-GUIDE.md` (optional — only if user wants it)
+
+**Background:** All 17 Playwright E2E test files (100+ scenarios) are blocked because the uni-app H5 frontend isn't deployed to a browser-accessible URL. The tests use hash-based routing (`/#/pages/...`) which requires uni-app's H5 build.
+
+- [ ] **Step 1: Document the deployment requirement**
+
+Update `tests/E2E-TEST-STATUS.md` to include:
+1. The exact steps needed to deploy uni-app H5 build
+2. The required nginx/Apache config for SPA hash routing
+3. The baseURL that should be set in `playwright.config.js`
+4. Reference to the existing cfc-web deployment on 251
+
+The deployment command would be:
+```bash
+cd cfc-frontend
+npm install
+npm run build:h5  # produces dist/build/h5/
+# Then rsync to web server (same nginx that serves cfc-web)
+```
+
+The `playwright.config.js` baseURL would need to change to wherever the H5 build is deployed.
+
+- [ ] **Step 2: Verify Playwright baseURL is configurable**
+
+Read `tests/playwright.config.js` to confirm the baseURL config, document how to switch between environments.
+
+---
+
+## Self-Review
+
+### Spec Coverage Check
+| Requirement | Task | Status |
+|-------------|------|--------|
+| Commit current work | Task 1 | ✅ |
+| Fix SKU NPE (NEW-02) | Task 2 | ✅ — verify first, fix if needed |
+| Investigate INFO-GA (guide 500) | Task 3 | ✅ — analyze, fix or classify |
+| Investigate ISSUE-001 (vendor login) | Task 4 | ✅ — analyze, fix or classify |
+| Update API tests | Task 5 | ✅ — add food/seasonal coverage |
+| Update issue tracking | Task 6 | ✅ |
+| Document E2E deployment | Task 7 | ✅ |
+
+### Placeholder Check
+No TBD/TODO/fill-in patterns found — all steps have concrete action descriptions.
+
+### Type Consistency
+- All file paths use absolute paths from repo root (consistent with AGENTS.md conventions)
+- Controller methods follow `@PostMapping` convention (verified in codebase)
+- API test script uses existing `v9_test.js` structure

+ 324 - 0
docs/superpowers/specs/2026-07-05-supply-distribution-system-design.md

@@ -0,0 +1,324 @@
+# 供应商分销体系 — 设计规格
+
+> **日期**: 2026-07-05
+> **状态**: 草稿
+> **范围**: 管理端后台(cfc-web) + 后端(cfc-backend)
+
+---
+
+## 1. 概述
+
+在管理后台建立供应商分销体系。每个供应商可创建或加入多个**供应商体系**(虚拟部门),每个体系独立配置账期和平台留利比例,体系内所有商品给平台的留利空间一致。体系管理员负责商品供应、定价、折扣和发货管理,并可指定货款打给哪个具体供应商。
+
+### 1.1 角色定义
+
+| 角色 | 说明 |
+|------|------|
+| **体系管理员** | 创建和管理供应商体系,管理商品/定价/折扣,指定货款收款方 |
+| **体系成员** | 加入体系的供应商,可被指定为收款方 |
+| **平台运营** | 查看所有体系数据,进行结算管理 |
+
+### 1.2 业务规则
+
+- 一个用户可加入**多个**供应商体系,在每个体系中角色独立(管理员或成员)
+- 同一体系内所有商品的**平台留利比例一致**,取自体系配置
+- **账期**按体系配置(如 30/60/90 天),到期生成结算单
+- 管理员可为每笔订单**指定货款收款方**(体系内的某个供应商成员)
+- 订单走现有商城流程(小程序 C 端下单),后台做供应商维度的管理
+
+---
+
+## 2. 数据模型
+
+### 2.1 新建表
+
+#### `supply_system` — 供应商体系
+
+```sql
+CREATE TABLE `supply_system` (
+  `id`          bigint       NOT NULL AUTO_INCREMENT,
+  `name`        varchar(100) NOT NULL COMMENT '体系名称',
+  `admin_id`    bigint       NOT NULL COMMENT '管理员用户ID',
+  `settlement_period_days` int NOT NULL DEFAULT 30 COMMENT '账期(天)',
+  `platform_profit_rate` decimal(5,2) NOT NULL DEFAULT 0 COMMENT '平台留利比例(%)',
+  `description` varchar(500) DEFAULT NULL COMMENT '描述',
+  `status`      varchar(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled',
+  `created_at`  datetime     NOT NULL,
+  `updated_at`  datetime     NOT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系';
+```
+
+#### `supply_system_member` — 体系成员
+
+```sql
+CREATE TABLE `supply_system_member` (
+  `id`         bigint       NOT NULL AUTO_INCREMENT,
+  `system_id`  bigint       NOT NULL COMMENT '体系ID',
+  `user_id`    bigint       NOT NULL COMMENT '用户ID',
+  `role`       varchar(20)  NOT NULL DEFAULT 'member' COMMENT '角色: admin/member',
+  `status`     varchar(20)  NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive',
+  `joined_at`  datetime     NOT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_system_user` (`system_id`, `user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体系成员';
+```
+
+#### `supply_settlement` — 结算记录
+
+```sql
+CREATE TABLE `supply_settlement` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `system_id`       bigint       NOT NULL COMMENT '体系ID',
+  `period_start`    date         NOT NULL COMMENT '结算周期开始',
+  `period_end`      date         NOT NULL COMMENT '结算周期结束',
+  `total_sales`     int          NOT NULL DEFAULT 0 COMMENT '销售总额(分)',
+  `platform_profit` int          NOT NULL DEFAULT 0 COMMENT '平台留利金额(分)',
+  `supplier_payout` int          NOT NULL DEFAULT 0 COMMENT '供应商实付(分)',
+  `status`          varchar(20)  NOT NULL DEFAULT 'pending' COMMENT '状态: pending/settled',
+  `settled_at`      datetime     DEFAULT NULL COMMENT '结算时间',
+  `remark`          varchar(500) DEFAULT NULL COMMENT '备注',
+  `created_at`      datetime     NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_system_id` (`system_id`),
+  KEY `idx_period` (`period_start`, `period_end`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算记录';
+```
+
+#### `supply_settlement_detail` — 结算明细
+
+```sql
+CREATE TABLE `supply_settlement_detail` (
+  `id`            bigint       NOT NULL AUTO_INCREMENT,
+  `settlement_id` bigint       NOT NULL COMMENT '结算ID',
+  `order_id`      bigint       NOT NULL COMMENT '订单ID',
+  `order_no`      varchar(64)  NOT NULL COMMENT '订单号',
+  `product_name`  varchar(200) NOT NULL COMMENT '商品名称',
+  `amount`        int          NOT NULL COMMENT '订单金额(分)',
+  `platform_fee`  int          NOT NULL COMMENT '平台留利(分)',
+  `supplier_id`   bigint       DEFAULT NULL COMMENT '收款供应商用户ID',
+  `supplier_name` varchar(100) DEFAULT NULL COMMENT '收款供应商名称',
+  `payout`        int          NOT NULL COMMENT '供应商实付(分)',
+  `created_at`    datetime     NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_settlement_id` (`settlement_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算明细';
+```
+
+### 2.2 扩展现有表
+
+#### `products` 表(已存在)
+
+现有字段可直接使用:
+
+| 字段 | 类型 | 说明 | 状态 |
+|------|------|------|------|
+| `distribution_system_id` | bigint | 所属供应商体系ID | ✅ 已有 |
+| `profit_rate` | int | 平台利润率(千分比) | ✅ 已有 |
+| `vendor_id` | bigint | 供应商用户ID | ✅ 已有 |
+| `vendor_name` | varchar | 供应商名称 | ✅ 已有 |
+
+**说明**:创建/编辑商品时,选择所属供应商体系后,`profit_rate` 自动填充为该体系的 `platform_profit_rate`(转换为千分比)。
+
+#### `product_orders` 表 — 新增字段
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `supply_system_id` | bigint | 所属供应商体系ID(冗余,方便查询) |
+| `supplier_user_id` | bigint | 该笔订单指定的收款供应商用户ID |
+
+---
+
+## 3. 后端设计
+
+### 3.1 新增文件
+
+| 文件 | 说明 |
+|------|------|
+| `entity/SupplySystem.java` | 供应商体系实体 |
+| `entity/SupplySystemMember.java` | 体系成员实体 |
+| `entity/SupplySettlement.java` | 结算记录实体 |
+| `entity/SupplySettlementDetail.java` | 结算明细实体 |
+| `mapper/SupplySystemMapper.java` | |
+| `mapper/SupplySystemMemberMapper.java` | |
+| `mapper/SupplySettlementMapper.java` | |
+| `mapper/SupplySettlementDetailMapper.java` | |
+| `service/SupplySystemService.java` | 体系 + 成员管理业务逻辑 |
+| `service/SupplySettlementService.java` | 结算业务逻辑 |
+| `controller/admin/SupplySystemController.java` | 体系/成员 CRUD API |
+| `controller/admin/SupplySettlementController.java` | 结算 API |
+
+### 3.2 API 端点
+
+**体系管理** (`SupplySystemController`, 路由前缀 `/api/admin/supply-system`)
+
+| 端点 | 方法 | 说明 |
+|------|------|------|
+| `/list` | POST | 体系列表(分页+搜索) |
+| `/create` | POST | 创建体系 |
+| `/update` | POST | 编辑体系 |
+| `/detail` | POST | 体系详情 |
+| `/toggle-status` | POST | 启用/禁用体系 |
+
+**成员管理**(同上 Controller)
+
+| 端点 | 方法 | 说明 |
+|------|------|------|
+| `/member/list` | POST | 体系成员列表 |
+| `/member/add` | POST | 添加成员 |
+| `/member/remove` | POST | 移除成员 |
+| `/member/change-role` | POST | 变更角色 |
+
+**结算管理** (`SupplySettlementController`, 路由前缀 `/api/admin/supply-settlement`)
+
+| 端点 | 方法 | 说明 |
+|------|------|------|
+| `/list` | POST | 结算记录列表 |
+| `/detail` | POST | 结算明细 |
+| `/create` | POST | 手动生成结算单 |
+| `/confirm` | POST | 确认结算 |
+
+**商品关联**(在现有 ProductEdit 流程中)
+
+| 端点 | 方法 | 说明 |
+|------|------|------|
+| `/api/admin/product/list-by-system` | POST | 查询某体系的商品列表 |
+
+### 3.3 商品关联逻辑
+
+在现有 `ProductEdit.vue` 中添加「所属供应商体系」下拉框:
+
+1. 选择体系 → `distribution_system_id` 写入 Product
+2. 自动填充 `profit_rate` = 体系 `platform_profit_rate × 10`(转为千分比)
+3. 后续编辑时 `profit_rate` 跟随体系配置,不可单独修改(规则约束)
+
+---
+
+## 4. 前端设计
+
+### 4.1 新增页面(cfc-web)
+
+```
+src/views/admin/supply-system/
+├── List.vue          — 供应商体系列表
+├── Detail.vue        — 体系详情(Tab: 商品/成员/订单/结算)
+└── Form.vue          — 新建/编辑体系
+```
+
+#### List.vue — 体系列表
+
+- 表格列:体系名称、管理员、账期、平台留利、商品数、成员数、状态、操作
+- 搜索:按名称搜索
+- 操作:新建、编辑、启用/禁用
+
+#### Detail.vue — 体系详情
+
+使用 el-tabs 分 Tab:
+
+| Tab | 内容 |
+|-----|------|
+| **商品** | 关联的商品列表,可跳转商品编辑 |
+| **成员** | 成员表格(用户名/角色/加入时间),可添加/移除/改角色 |
+| **订单** | 该体系下的订单列表,支持指定收款供应商 |
+| **结算** | 结算记录列表,可生成/查看结算单 |
+
+#### Form.vue — 体系表单
+
+| 字段 | 组件 | 说明 |
+|------|------|------|
+| 体系名称 | el-input | 必填 |
+| 管理员 | el-select (搜索用户) | 必填 |
+| 账期(天) | el-input-number | 30/60/90 |
+| 平台留利(%) | el-input-number | 0-100,小数点后2位 |
+| 描述 | el-textarea | 选填 |
+| 状态 | el-switch | active/disabled |
+
+### 4.2 修改现有页面
+
+#### ProductEdit.vue — 新增字段
+
+在基本信息区域增加「所属供应商体系」选择器:
+
+- 下拉选项:所有 active 的 supply_system
+- 选择后自动填充 `profit_rate`(只读显示)
+- 不选择则商品不属于任何分销体系
+
+#### Layout.vue — 侧边栏菜单
+
+```
+供应商管理
+├── 供应商体系(新增,路径 /supply-system)
+└── ... 现有菜单
+```
+
+---
+
+## 5. 订单与结算流程
+
+### 5.1 指定货款收款方
+
+流程:
+1. 管理员在后台「订单详情」中看到属于自己体系的订单
+2. 点击「指定收款方」→ 弹出该体系的成员列表
+3. 选择成员 → `product_orders.supplier_user_id` 更新
+4. 可随时修改,以结算时的指定为准
+
+### 5.2 结算流程
+
+1. 管理员或平台运营点击「生成结算单」
+2. 系统汇总指定周期内该体系下所有已完成的订单
+3. 计算:`总销售额 → 平台留利 → 供应商实付`
+4. 生成 `supply_settlement` + 对应的 `supply_settlement_detail` 记录
+5. 平台运营审核确认 → 状态改为 `settled`
+
+计算公式:
+```
+platform_profit = total_sales × platform_profit_rate / 100
+supplier_payout = total_sales - platform_profit
+```
+
+---
+
+## 6. 与现有系统的集成
+
+| 现有系统 | 集成方式 |
+|---------|----------|
+| **Product** | 加 system_id 关联,profit_rate 自动填充 |
+| **ProductOrder** | 加 system_id + supplier_user_id |
+| **User** | 直接使用,admin_id 和 member user_id 都引用 User |
+| **商品管理** | ProductEdit.vue 增加「所属体系」字段 |
+| **订单管理** | OrderManage.vue 增加「体系列」筛选和「指定收款方」操作 |
+| **权限系统** | `supply:*` 权限码,admin 角色可见 |
+
+---
+
+## 7. 不在此次范围内的功能
+
+- 小程序端供应商商品展示(C 端走现有商城,无需修改)
+- 支付分账(平台先收全款,线下与供应商结算)
+- 自动结算(仅支持手动生成)
+- 供应商自主申请入驻(由管理端后台创建)
+
+---
+
+## 8. 数据库 SQL 迁移
+
+```sql
+-- 1. supply_system
+CREATE TABLE IF NOT EXISTS `supply_system` (...);
+
+-- 2. supply_system_member
+CREATE TABLE IF NOT EXISTS `supply_system_member` (...);
+
+-- 3. supply_settlement
+CREATE TABLE IF NOT EXISTS `supply_settlement` (...);
+
+-- 4. supply_settlement_detail
+CREATE TABLE IF NOT EXISTS `supply_settlement_detail` (...);
+
+-- 5. product_orders 新增字段
+ALTER TABLE `product_orders`
+  ADD COLUMN `supply_system_id` bigint DEFAULT NULL COMMENT '所属供应商体系ID' AFTER `coupon_id`,
+  ADD COLUMN `supplier_user_id` bigint DEFAULT NULL COMMENT '收款供应商用户ID' AFTER `supply_system_id`,
+  ADD KEY `idx_supply_system_id` (`supply_system_id`);
+```