Browse Source

feat(article-center): 文章/商品详情页关联文章+商品(文章在上商品在下、商城样式触底分页)+ 答题选中态修复

- 文章详情页:相关文章上移(每页3篇触底分页),相关商品改为商城两列大卡片(每页4个触底分页)
- 商品详情页:新增相关文章(商品标签匹配文章)+ 相关商品(商品标签匹配商品,排除自身),文章在上商品在下,均支持分页
- 后端:/api/articles/related 增加 page 参数;新增 /api/articles/related-by-product 与 /api/product/related-products(均入 JwtInterceptor/WebConfig 白名单)
- ProductRecommendationService 抽出标签匹配核心(getTagMatchedProducts),文章/商品两场景复用
- 答题:selectQuizAnswer 改用 \ 修复 Vue2 数组索引不响应导致选中态不显示;新增 currentQuestionOptions 计算属性统一派生字母/剥离后端前缀,规避模板内方法调用
liaoxg 4 days ago
parent
commit
fd4cbbf3d8

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -48,6 +48,7 @@ private static final String[] PUBLIC_PATHS = {
          "/api/product/list",
          "/api/product/detail",
          "/api/product/type-list",
+         "/api/product/related-products",
          "/api/mini-game/list",
          "/api/admin-auth/send-code",
          "/api/admin-auth/login",
@@ -60,6 +61,7 @@ private static final String[] PUBLIC_PATHS = {
          "/api/articles/record-read",
          "/api/articles/related-products",
         "/api/articles/related",
+        "/api/articles/related-by-product",
          "/api/growth/**",
          "/api/activity/list",
          "/api/activity/detail",

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -111,6 +111,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/product/list",
                 "/api/product/detail",
                 "/api/product/type-list",
+                "/api/product/related-products",
                 "/api/mini-game/list",
                 "/api/articles/list",
                 "/api/articles/detail",
@@ -119,6 +120,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/record-read",
                 "/api/articles/related-products",
                 "/api/articles/related",
+                "/api/articles/related-by-product",
                 "/api/growth/**",
                 "/api/articles/updated-since",
                 "/api/activity/list",
@@ -158,6 +160,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/product/list",
                 "/api/product/detail",
                 "/api/product/type-list",
+                "/api/product/related-products",
                 "/api/mini-game/list",
                 "/api/articles/list",
                 "/api/articles/detail",
@@ -166,6 +169,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/record-read",
                 "/api/articles/related-products",
                 "/api/articles/related",
+                "/api/articles/related-by-product",
                 "/api/growth/**",
                 "/api/activity/list",
                 "/api/activity/detail",

+ 34 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -73,7 +73,8 @@ public class ArticleController {
     }
 
     /**
-     * 相关文章:按标签关联(共享标签数优先,发布先后次之),默认 3 篇;无匹配返回空数组
+     * 相关文章:按标签关联(共享标签数优先,发布先后次之),默认 3 篇/页;无匹配返回空数组。
+     * 支持分页:page 从 1 开始,每页 size 篇。
      */
     @PostMapping("/related")
     public Result<List<Article>> related(@RequestBody Map<String, Object> body,
@@ -85,6 +86,37 @@ public class ArticleController {
         }
         int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 3;
         if (size < 1) size = 1;
+        int page = body.get("page") != null ? Math.max(1, Integer.parseInt(body.get("page").toString())) : 1;
+
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        List<Article> list = articleService.getRelatedArticles(articleId, size, page, userId, familyId, vendorType);
+        return Result.success(list);
+    }
+
+    /**
+     * 商品详情页相关文章:按商品标签匹配文章(共享标签数优先),支持分页;无匹配返回空数组。
+     * 游客可访问(白名单),未登录无家庭可见性加成。
+     */
+    @PostMapping("/related-by-product")
+    public Result<List<Article>> relatedByProduct(@RequestBody Map<String, Object> body,
+                                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                                   @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long productId = ParamUtils.getLong(body.get("productId"));
+        if (productId == null) {
+            return Result.error("productId不能为空");
+        }
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 3;
+        if (size < 1) size = 1;
+        int page = body.get("page") != null ? Math.max(1, Integer.parseInt(body.get("page").toString())) : 1;
 
         Long familyId = null;
         String vendorType = null;
@@ -96,7 +128,7 @@ public class ArticleController {
             }
         }
 
-        List<Article> list = articleService.getRelatedArticles(articleId, size, userId, familyId, vendorType);
+        List<Article> list = articleService.getRelatedArticlesByProduct(productId, size, page, userId, familyId, vendorType);
         return Result.success(list);
     }
 

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -5,7 +5,10 @@ import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.PpointService;
+import com.etotem.cfc.service.ProductRecommendationService;
 import com.etotem.cfc.service.ProductService;
 import com.etotem.cfc.service.ProductSkuService;
 import com.alibaba.fastjson.JSON;
@@ -20,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -40,6 +44,12 @@ public class ProductController {
     @Resource
     private PpointService ppointService;
 
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
+    @Resource
+    private UserMapper userMapper;
+
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody ProductListQueryDTO query,
                                              @RequestAttribute(value = "userId", required = false) Long userId,
@@ -208,6 +218,39 @@ public class ProductController {
         return Result.success(result);
     }
 
+    /**
+     * 商品详情页「相关商品」:按当前商品标签匹配其他商品(排除自身),
+     * 按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序;支持分页(默认 4 个/页)。
+     * 登录/匿名均可返回(白名单),未登录无权重加成。
+     */
+    @PostMapping("/related-products")
+    public Result<List<Map<String, Object>>> relatedProducts(@RequestBody Map<String, Object> params,
+                                                              @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long productId = ParamUtils.getLong(params.get("productId"));
+        if (productId == null) {
+            return Result.error("productId不能为空");
+        }
+        int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 4;
+        if (size < 1) size = 4;
+        int page = params.get("page") != null ? Math.max(1, Integer.parseInt(params.get("page").toString())) : 1;
+
+        Long familyId = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+            }
+        }
+
+        // 全量候选 → 按页切片(与 /api/articles/related-products 分页语义一致,保证翻页不重复、不遗漏)
+        int fetchLimit = page * size;
+        List<Map<String, Object>> all = productRecommendationService.getRelatedProductsByProductFull(productId, userId, familyId, fetchLimit);
+        int from = (page - 1) * size;
+        int to = Math.min(from + size, all.size());
+        List<Map<String, Object>> result = from >= all.size() ? Collections.emptyList() : all.subList(from, to);
+        return Result.success(result);
+    }
+
     private List<Map<String, String>> parseSpecs(String specsStr) {
         String trimmed = specsStr.trim();
         List<Map<String, String>> result = new ArrayList<>();

+ 76 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -9,9 +9,11 @@ import com.etotem.cfc.entity.ArticleQuizRecord;
 import com.etotem.cfc.entity.ArticleReadingRecord;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
 import com.etotem.cfc.mapper.ArticleReadingRecordMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
@@ -40,6 +42,9 @@ public class ArticleService {
     @Resource
     private ArticleMapper articleMapper;
 
+    @Resource
+    private ProductMapper productMapper;
+
     @Resource
     private ArticlePermissionService articlePermissionService;
 
@@ -197,9 +202,10 @@ public class ArticleService {
 
     /**
      * 相关文章:按标签匹配(FIND_IN_SET 精确匹配整标签),共享标签数降序 → 发布时间降序,
-     * 排除自身,仅取已发布,经可见性过滤后截取前 size 篇。无标签/无匹配返回空。
+     * 排除自身,仅取已发布,经可见性过滤后按 (page-1)*size ~ page*size 分页窗口截取。
+     * 无标签/无匹配返回空。
      */
-    public List<Article> getRelatedArticles(Long articleId, int size, Long userId, Long familyId, String vendorType) {
+    public List<Article> getRelatedArticles(Long articleId, int size, int page, Long userId, Long familyId, String vendorType) {
         Article current = articleMapper.selectById(articleId);
         if (current == null || current.getTags() == null || current.getTags().trim().isEmpty()) {
             return Collections.emptyList();
@@ -211,13 +217,39 @@ public class ArticleService {
                 tags.add(tag);
             }
         }
-        if (tags.isEmpty()) {
+        return matchArticlesByTags(tags, articleId, size, page, userId, familyId, vendorType);
+    }
+
+    /**
+     * 商品详情页「相关文章」:按商品标签匹配文章(共享标签数降序 → 发布时间降序),
+     * 支持分页窗口截取。商品标签为 JSON 数组或逗号分隔字符串。
+     */
+    public List<Article> getRelatedArticlesByProduct(Long productId, int size, int page, Long userId, Long familyId, String vendorType) {
+        if (productId == null) return Collections.emptyList();
+        Product product = productMapper.selectById(productId);
+        if (product == null || product.getTags() == null || product.getTags().trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> tags = parseCommaOrJsonTags(product.getTags());
+        // 商品来源无需排除文章自身(productId 与 articleId 分属不同表),excludeArticleId 传 null
+        return matchArticlesByTags(tags, null, size, page, userId, familyId, vendorType);
+    }
+
+    /**
+     * 标签匹配文章核心逻辑:FIND_IN_SET 候选 → 共享标签计数 → 排序 → 可见性过滤 → 分页窗口截取。
+     */
+    private List<Article> matchArticlesByTags(List<String> tags, Long excludeArticleId, int size, int page,
+                                              Long userId, Long familyId, String vendorType) {
+        if (tags == null || tags.isEmpty() || size < 1) {
             return Collections.emptyList();
         }
+        int p = page < 1 ? 1 : page;
 
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
-                .eq(Article::getStatus, "published")
-                .ne(Article::getId, articleId);
+                .eq(Article::getStatus, "published");
+        if (excludeArticleId != null) {
+            wrapper.ne(Article::getId, excludeArticleId);
+        }
         wrapper.and(w -> {
             boolean first = true;
             for (String tag : tags) {
@@ -235,7 +267,7 @@ public class ArticleService {
         Map<Long, Integer> score = new HashMap<>();
         List<Article> matched = new ArrayList<>();
         for (Article a : candidates) {
-            if (a.getId() != null && a.getId().equals(articleId)) {
+            if (excludeArticleId != null && a.getId() != null && a.getId().equals(excludeArticleId)) {
                 continue;
             }
             int s = 0;
@@ -268,7 +300,44 @@ public class ArticleService {
         if (visible == null) {
             visible = Collections.emptyList();
         }
-        return visible.size() > size ? new ArrayList<>(visible.subList(0, size)) : visible;
+        // 分页窗口截取:兼容旧调用方(size 截断)与分页调用方
+        int from = (p - 1) * size;
+        if (from >= visible.size()) {
+            return Collections.emptyList();
+        }
+        int to = Math.min(from + size, visible.size());
+        return new ArrayList<>(visible.subList(from, to));
+    }
+
+    /** 商品标签解析:兼容 JSON 数组字符串(["标签1","标签2"])与逗号分隔(标签1,标签2) */
+    private List<String> parseCommaOrJsonTags(String raw) {
+        if (raw == null || raw.trim().isEmpty()) return Collections.emptyList();
+        String trimmed = raw.trim();
+        if (trimmed.startsWith("[")) {
+            try {
+                com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(trimmed);
+                if (arr == null) return Collections.emptyList();
+                List<String> tags = new ArrayList<>();
+                for (int i = 0; i < arr.size(); i++) {
+                    Object o = arr.get(i);
+                    if (o != null && !o.toString().trim().isEmpty()) {
+                        tags.add(o.toString().trim());
+                    }
+                }
+                return tags;
+            } catch (Exception e) {
+                log.warn("解析商品标签失败: {}", e.getMessage());
+                return Collections.emptyList();
+            }
+        }
+        List<String> tags = new ArrayList<>();
+        for (String t : trimmed.split(",")) {
+            String tag = t.trim();
+            if (!tag.isEmpty() && !tags.contains(tag)) {
+                tags.add(tag);
+            }
+        }
+        return tags;
     }
 
     public Article getDetail(Long id, Long userId, Long familyId, String vendorType) {

+ 48 - 13
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -870,23 +870,57 @@ public class ProductRecommendationService {
     }
 
     /**
-     * 文章标签匹配商品推荐:通过文章标签与商品标签匹配,按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序
-     *
-     * @param articleId 文章ID
-     * @param userId    当前用户ID(可为空,用于用户权重)
-     * @param familyId  家庭ID(可为空,用于家庭权重)
-     * @param limit     返回数量
-     * @return 关联商品列表(无匹配时返回空列表,由调用方决定是否兜底)
+     * 文章标签匹配商品推荐(包装方法):通过文章标签与商品标签匹配,按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序
      */
     private List<Map<String, Object>> getArticleTagMatchedProducts(Long articleId, Long userId, Long familyId, int limit) {
-        // 1. 获取文章标签
         Article article = articleMapper.selectById(articleId);
         if (article == null || article.getTags() == null || article.getTags().trim().isEmpty()) {
             return Collections.emptyList();
         }
         List<String> articleTags = parseTagList(article.getTags());
-        if (articleTags.isEmpty()) return Collections.emptyList();
-        Set<String> articleTagSet = new HashSet<>(articleTags);
+        // 文章场景无需排除商品(文章与商品主键分属不同表),excludeProductId 传 null
+        return getTagMatchedProducts(articleTags, null, userId, familyId, limit, "文章", "article_tag");
+    }
+
+    /**
+     * 商品详情页「相关商品」完整候选列表:按商品标签匹配商品(排除自身),排序与分页语义与文章关联商品一致。
+     * 调用方自行切片实现分页。
+     *
+     * @param productId 商品ID
+     * @param userId    当前用户ID(可为空,用于用户权重)
+     * @param familyId  家庭ID(可为空,用于家庭权重)
+     * @param limit     候选上限(0 表示不限制,由调用方决定分页窗口)
+     * @return 关联商品完整列表(无匹配时返回空列表)
+     */
+    public List<Map<String, Object>> getRelatedProductsByProductFull(Long productId, Long userId, Long familyId, int limit) {
+        if (productId == null) return Collections.emptyList();
+        Product product = productMapper.selectById(productId);
+        if (product == null || product.getTags() == null || product.getTags().trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> tags = parseTagList(product.getTags());
+        return getTagMatchedProducts(tags, productId, userId, familyId, limit, "商品", "product_tag");
+    }
+
+    /**
+     * 标签匹配商品推荐核心逻辑:通过源标签(文章/商品)与商品标签匹配,按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序
+     *
+     * @param sourceTags       源标签集合(来自文章或商品)
+     * @param excludeProductId 需排除的商品ID(商品关联商品时排除自身;文章关联商品时为 null)
+     * @param userId           当前用户ID(可为空,用于用户权重)
+     * @param familyId         家庭ID(可为空,用于家庭权重)
+     * @param limit            返回数量
+     * @param sourceDesc       来源描述("文章"/"商品",用于 reason 文案)
+     * @param sourceCode       来源编码(article_tag/product_tag,写入 source 字段)
+     * @return 关联商品列表(无匹配时返回空列表,由调用方决定是否兜底)
+     */
+    private List<Map<String, Object>> getTagMatchedProducts(List<String> sourceTags, Long excludeProductId,
+                                                            Long userId, Long familyId, int limit,
+                                                            String sourceDesc, String sourceCode) {
+        if (sourceTags == null || sourceTags.isEmpty() || limit <= 0) {
+            return Collections.emptyList();
+        }
+        Set<String> sourceTagSet = new HashSet<>(sourceTags);
 
         // 2. 查询所有在售且有库存的商品
         List<Product> products = productMapper.selectList(
@@ -934,13 +968,14 @@ public class ProductRecommendationService {
         // 5. 计算标签匹配数与权重,构建候选列表
         List<Map<String, Object>> candidates = new ArrayList<>();
         for (Product product : products) {
+            if (excludeProductId != null && excludeProductId.equals(product.getId())) continue;
             if (product.getTags() == null || product.getTags().trim().isEmpty()) continue;
             List<String> productTags = parseTagList(product.getTags());
             if (productTags.isEmpty()) continue;
 
             int matchCount = 0;
             for (String tag : productTags) {
-                if (articleTagSet.contains(tag)) matchCount++;
+                if (sourceTagSet.contains(tag)) matchCount++;
             }
             if (matchCount == 0) continue;
 
@@ -958,8 +993,8 @@ public class ProductRecommendationService {
             item.put("tagMatchCount", matchCount);
             item.put("userWeight", userWeight);
             item.put("familyWeight", familyWeight);
-            item.put("reason", "与文章标签匹配 " + matchCount + " 个标签");
-            item.put("source", "article_tag");
+            item.put("reason", "与" + sourceDesc + "标签匹配 " + matchCount + " 个标签");
+            item.put("source", sourceCode);
             item.put("url", "/pages/discover-detail/product-detail/product-detail?id=" + product.getId());
             candidates.add(item);
         }

+ 119 - 29
cfc-frontend/pages/article-center/article-detail.vue

@@ -41,16 +41,7 @@
         <view class="divider"></view>
         <view class="detail-body"><mp-html :content="article.content" :domain="config.API_BASE_URL" :tag-style="contentTagStyle" /></view>
 
-        <!-- 关联商品推荐(文章标签 ↔ 商品标签匹配,登录/匿名均可返回) -->
-        <view v-if="relatedProducts.length > 0" class="related-section">
-          <view class="related-header">
-            <text class="related-title">相关商品</text>
-            <text class="related-subtitle">根据文章内容推荐</text>
-          </view>
-          <dimension-products :products="relatedProducts" @productClick="goRelatedProduct" />
-        </view>
-
-        <!-- 相关文章(按标签关联,无匹配整块隐藏) -->
+        <!-- 相关文章(按标签关联,无匹配整块隐藏;触底分页加载) -->
         <view v-if="relatedArticles.length > 0" class="related-section">
           <view class="related-header">
             <text class="related-title">相关文章</text>
@@ -71,6 +62,18 @@
               </view>
             </view>
           </view>
+          <view v-if="relatedArticlesLoading" class="related-loading-hint">加载中...</view>
+        </view>
+
+        <!-- 关联商品推荐(文章标签 ↔ 商品标签匹配,登录/匿名均可返回;商城两列大卡片样式,触底分页加载) -->
+        <view v-if="relatedProducts.length > 0" class="related-section">
+          <view class="related-header">
+            <text class="related-title">相关商品</text>
+            <text class="related-subtitle">根据文章内容推荐</text>
+          </view>
+          <dimension-products :products="relatedProducts" layout="mall" @productClick="goRelatedProduct" />
+          <view v-if="relatedProductsLoading" class="related-loading-hint">加载中...</view>
+          <view v-else-if="relatedProductsNoMore" class="related-loading-hint dim">没有更多了</view>
         </view>
 
         <!-- 评论区(已屏蔽) -->
@@ -108,7 +111,11 @@
           </view>
           <text class="q-title">{{ currentQuestion.question }}</text>
           <view class="q-options">
-            <text v-for="(opt, oi) in currentQuestion.options" :key="oi" class="q-option" :class="{ selected: quizAnswers[currentQuizIndex] === String.fromCharCode(65 + oi) }" @click="selectQuizAnswer(currentQuizIndex, oi)">{{ opt }}</text>
+            <view v-for="(opt, oi) in currentQuestionOptions" :key="oi" class="q-option-row" :class="opt.selected ? 'selected' : ''" @click="selectQuizAnswer(currentQuizIndex, oi)">
+              <text class="q-option-letter">{{ opt.letter }}.</text>
+              <text class="q-option-text">{{ opt.text }}</text>
+              <text class="q-option-check">✓</text>
+            </view>
           </view>
           <view class="quiz-nav">
             <button v-if="currentQuizIndex > 0" class="quiz-prev" @click="goPrevQuestion">上一题</button>
@@ -148,6 +155,9 @@ export default {
       articleId: '', article: null, loading: true, error: false, errorMsg: '',
       relatedProducts: [],
       relatedArticles: [],
+      // 关联区块分页状态
+      relatedProductsPage: 1, relatedProductsLoading: false, relatedProductsNoMore: false,
+      relatedArticlesPage: 1, relatedArticlesLoading: false, relatedArticlesNoMore: false,
       readingSeconds: 0, lastReportedSeconds: 0, readingCompleted: false,
       isTimerRunning: false, timerHandle: null, syncHandle: null,
       currentChildId: null, isLogin: false,
@@ -173,6 +183,20 @@ export default {
     currentQuestion: function() {
       if (!this.quizQuestions || !this.quizQuestions.length) return null
       return this.quizQuestions[this.currentQuizIndex] || null
+    },
+    // 当前题的选项展示数据:统一派生字母、剥离后端自带前缀、标记选中态。
+    // 避免在模板 :class / 插值中调用方法(小程序渲染层不支持)且数组索引访问不响应。
+    currentQuestionOptions: function() {
+      var q = this.currentQuestion
+      if (!q || !q.options) return []
+      var selected = this.quizAnswers[this.currentQuizIndex] || ''
+      return q.options.map(function(opt, oi) {
+        var letter = String.fromCharCode(65 + oi)
+        var s = opt ? opt.toString().trim() : ''
+        // 剥离 "A." / "A)" / "(A)" 等常见前缀,字母由下方单独渲染避免重复
+        var cleaned = s.replace(/^[A-Za-z][\.\)\::]\s*/i, '').trim()
+        return { letter: letter, text: cleaned, selected: selected === letter }
+      })
     }
   },
   onLoad(options) {
@@ -220,33 +244,78 @@ export default {
           this.article.content = this.resolveContentUrls(this.article.content)
           this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
           this.startReadingTimer()
-          // 关联商品:放在 loadDetail 内只走一次(onShow 不重复请求,避免双发)
-          this.loadRelatedProducts(id)
-          // 相关文章:同样只走一次,无匹配时为空数组 → 模板整块隐藏
-          this.loadRelatedArticles(id)
+          // 关联区块:放在 loadDetail 内只走一次(onShow 不重复请求,避免双发);先重置分页状态再加载第 1 页
+          this.resetRelatedState()
+          this.loadRelatedProducts(id, 1, false)
+          this.loadRelatedArticles(id, 1, false)
         } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
       } catch (e) { this.error = true; this.errorMsg = '加载失败' }
       finally { this.loading = false }
     },
-    // 加载文章关联商品(后端按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序)
-    loadRelatedProducts: function(articleId) {
+    // 重置关联区块分页状态(loadDetail 每次加载前调用,避免切文章后旧状态残留)
+    resetRelatedState: function() {
+      this.relatedProductsPage = 1
+      this.relatedProductsLoading = false
+      this.relatedProductsNoMore = false
+      this.relatedArticlesPage = 1
+      this.relatedArticlesLoading = false
+      this.relatedArticlesNoMore = false
+    },
+    // 加载文章关联商品(后端按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序),每页 4 个,触底翻页
+    loadRelatedProducts: function(articleId, page, append) {
       var self = this
       if (!articleId) return
-      getArticleRelatedProducts({ articleId: articleId, size: 4 }).then(function(res) {
+      var pg = page || 1
+      if (pg === 1) {
+        this.relatedProducts = []
+        this.relatedProductsNoMore = false
+      }
+      if (this.relatedProductsLoading) return
+      this.relatedProductsLoading = true
+      getArticleRelatedProducts({ articleId: articleId, size: 4, page: pg }).then(function(res) {
+        self.relatedProductsLoading = false
         if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
-          self.relatedProducts = res.data
+          if (append && pg > 1) {
+            self.relatedProducts = self.relatedProducts.concat(res.data)
+          } else {
+            self.relatedProducts = res.data
+          }
+          if (res.data.length < 4) self.relatedProductsNoMore = true
+        } else {
+          self.relatedProductsNoMore = true
         }
-      }).catch(function() { self.relatedProducts = [] })
+      }).catch(function() {
+        self.relatedProductsLoading = false
+        self.relatedProductsNoMore = true
+      })
     },
-    // 加载相关文章(后端按共享标签数降序,无匹配返回空数组 → 前端整块隐藏)
-    loadRelatedArticles: function(articleId) {
+    // 加载相关文章(后端按共享标签数降序),每页 3 篇,触底翻页;无匹配返回空数组 → 前端整块隐藏
+    loadRelatedArticles: function(articleId, page, append) {
       var self = this
       if (!articleId) return
-      getRelatedArticles({ articleId: articleId, size: 3 }).then(function(res) {
+      var pg = page || 1
+      if (pg === 1) {
+        this.relatedArticles = []
+        this.relatedArticlesNoMore = false
+      }
+      if (this.relatedArticlesLoading) return
+      this.relatedArticlesLoading = true
+      getRelatedArticles({ articleId: articleId, size: 3, page: pg }).then(function(res) {
+        self.relatedArticlesLoading = false
         if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
-          self.relatedArticles = res.data
+          if (append && pg > 1) {
+            self.relatedArticles = self.relatedArticles.concat(res.data)
+          } else {
+            self.relatedArticles = res.data
+          }
+          if (res.data.length < 3) self.relatedArticlesNoMore = true
+        } else {
+          self.relatedArticlesNoMore = true
         }
-      }).catch(function() { self.relatedArticles = [] })
+      }).catch(function() {
+        self.relatedArticlesLoading = false
+        self.relatedArticlesNoMore = true
+      })
     },
     // 跳转相关文章详情
     goRelatedArticle: function(item) {
@@ -294,7 +363,18 @@ export default {
       }
       return best ? best.code : ''
     },
-    onScrollToBottom() {},
+    onScrollToBottom: function() {
+      // 触底优先翻页「相关商品」(页面下方区块),商品加载完再翻「相关文章」
+      if (this.relatedProducts.length > 0 && !this.relatedProductsNoMore && !this.relatedProductsLoading) {
+        this.relatedProductsPage++
+        this.loadRelatedProducts(this.articleId, this.relatedProductsPage, true)
+        return
+      }
+      if (this.relatedArticles.length > 0 && !this.relatedArticlesNoMore && !this.relatedArticlesLoading) {
+        this.relatedArticlesPage++
+        this.loadRelatedArticles(this.articleId, this.relatedArticlesPage, true)
+      }
+    },
     startNewReadingSession: function() {
       // 每次进入文章详情页都从 0 开始新会话,时长不延续上一次
       this.readingSeconds = 0
@@ -471,7 +551,9 @@ export default {
       this.showResult = false
     },
     selectQuizAnswer: function(qi, oi) {
-      this.quizAnswers[qi] = String.fromCharCode(65 + oi)
+      // Vue 2 中数组索引直接赋值(quizAnswers[qi] = x)不触发响应式更新,
+      // 必须用 $set 才能驱动选中态/圆点高亮刷新
+      this.$set(this.quizAnswers, qi, String.fromCharCode(65 + oi))
     },
     // 一题一页:上一题 / 下一题
     goPrevQuestion: function() {
@@ -522,6 +604,8 @@ export default {
 .related-header { display: flex; align-items: baseline; margin-bottom: 16rpx; }
 .related-title { font-size: 30rpx; font-weight: bold; color: #333; }
 .related-subtitle { font-size: 20rpx; color: #999; margin-left: 12rpx; }
+.related-loading-hint { text-align: center; font-size: 22rpx; color: #F97316; padding: 16rpx 0 8rpx; }
+.related-loading-hint.dim { color: #bbb; }
 
 /* 相关文章 */
 .ra-list { display: flex; flex-direction: column; gap: 16rpx; }
@@ -557,8 +641,14 @@ export default {
 .quiz-dot.done { background: #FDBA74; }
 .q-title { font-size: 30rpx; font-weight: 600; color: #333; line-height: 1.5; margin-bottom: 20rpx; }
 .q-options { display: flex; flex-direction: column; gap: 12rpx; }
-.q-option { padding: 16rpx 20rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; font-size: 26rpx; color: #333; }
-.q-option.selected { border-color: #F97316; background: #FFF7ED; color: #92400E; }
+.q-option-row { display: flex; align-items: center; padding: 16rpx 20rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; }
+.q-option-row.selected { border-color: #F97316; background: #FFF7ED; }
+.q-option-letter { width: 44rpx; font-size: 26rpx; font-weight: 600; color: #666; flex-shrink: 0; }
+.q-option-row.selected .q-option-letter { color: #F97316; }
+.q-option-text { flex: 1; min-width: 0; font-size: 26rpx; color: #333; }
+.q-option-row.selected .q-option-text { color: #92400E; }
+.q-option-check { width: 32rpx; height: 32rpx; border-radius: 50%; border: 3rpx solid #D1D5DB; color: transparent; font-size: 20rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
+.q-option-row.selected .q-option-check { border-color: #F97316; background: #F97316; color: #fff; }
 .quiz-nav { display: flex; gap: 16rpx; margin-top: 28rpx; }
 .quiz-prev { flex: 1; padding: 20rpx; background: #fff; color: #F97316; border: 2rpx solid #F97316; font-size: 28rpx; border-radius: 12rpx; }
 .quiz-prev::after { border: none; }

+ 160 - 4
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -5,7 +5,7 @@
     <view v-if="loading" class="loading-wrap">
       <text class="loading-text">加载中...</text>
     </view>
-    <scroll-view v-else-if="product.id" scroll-y class="content">
+    <scroll-view v-else-if="product.id" scroll-y class="content" @scrolltolower="onScrollToBottom">
       <swiper class="cover-swiper" indicator-dots autoplay circular indicator-active-color="#F97316">
         <swiper-item v-for="(img, idx) in displayImages" :key="idx">
           <image class="swiper-image" :src="getImageUrl(img)" mode="widthFix" @error="onSwiperImageError(idx)" />
@@ -123,6 +123,41 @@
           <text class="bundle-item-qty">×{{ bi.quantity }}</text>
         </view>
       </view>
+
+      <!-- 相关文章(按商品标签关联,无匹配整块隐藏;触底分页加载) -->
+      <view v-if="relatedArticles.length > 0" class="related-section">
+        <view class="related-header">
+          <text class="related-title">相关文章</text>
+          <text class="related-subtitle">根据标签推荐</text>
+        </view>
+        <view class="ra-list">
+          <view
+            v-for="item in relatedArticles"
+            :key="item.id"
+            class="ra-item"
+            hover-class="ra-item-pressed"
+            @click="goRelatedArticle(item)"
+          >
+            <image class="ra-cover" :src="getArticleCover(item)" mode="aspectFill" />
+            <view class="ra-body">
+              <text class="ra-title">{{ item.title }}</text>
+              <text class="ra-summary" v-if="item.summary">{{ item.summary }}</text>
+            </view>
+          </view>
+        </view>
+        <view v-if="relatedArticlesLoading" class="related-loading-hint">加载中...</view>
+      </view>
+
+      <!-- 相关商品(按商品标签匹配,商城两列大卡片样式,触底分页加载) -->
+      <view v-if="relatedProducts.length > 0" class="related-section">
+        <view class="related-header">
+          <text class="related-title">相关商品</text>
+          <text class="related-subtitle">根据标签推荐</text>
+        </view>
+        <dimension-products :products="relatedProducts" layout="mall" @productClick="goRelatedProduct" />
+        <view v-if="relatedProductsLoading" class="related-loading-hint">加载中...</view>
+        <view v-else-if="relatedProductsNoMore" class="related-loading-hint dim">没有更多了</view>
+      </view>
     </scroll-view>
     <view v-else class="empty-wrap">
       <text class="empty-text">商品不存在或已下架</text>
@@ -149,14 +184,15 @@
 
 <script>
 import config from '@/config.js'
-import { productDetail, productOrderCreate, productOrderPay, cartAdd, getShareQrCode, productSpecMap, getMyMembership } from '@/utils/api.js'
+import { productDetail, productOrderCreate, productOrderPay, cartAdd, getShareQrCode, productSpecMap, getMyMembership, getRelatedArticlesByProduct, getRelatedProductsByProduct } from '@/utils/api.js'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
+import DimensionProducts from '@/components/DimensionProducts.vue'
 import shareMixin from '../../../components/share-mixin.js'
 import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 
 export default {
-  components: { MpHtml, ContentSharePoster },
+  components: { MpHtml, ContentSharePoster, DimensionProducts },
   mixins: [shareMixin],
   computed: {
     contentTagStyle() {
@@ -181,7 +217,12 @@ export default {
       // 会员身份(实时调 getMyMembership,FREE=非会员)
       isMember: false,
       memberLevel: 'FREE',
-      bundleItems: []
+      bundleItems: [],
+      // 关联区块(相关文章在上、相关商品在下,触底分页)
+      relatedArticles: [],
+      relatedProducts: [],
+      relatedArticlesPage: 1, relatedArticlesLoading: false, relatedArticlesNoMore: false,
+      relatedProductsPage: 1, relatedProductsLoading: false, relatedProductsNoMore: false
     }
   },
   computed: {
@@ -363,6 +404,103 @@ export default {
       if (path.startsWith('http') || path.startsWith('/static/')) return path
       return config.API_BASE_URL + path
     },
+    // 文章封面图(空值回退默认图,避免复用商品默认图)
+    getArticleCover: function(item) {
+      var p = item && item.coverImage
+      if (!p) return '/static/default-article.png'
+      if (p.indexOf('http') === 0 || p.indexOf('/static/') === 0) return p
+      return config.API_BASE_URL + p
+    },
+    // 重置关联区块分页状态(loadDetail 每次加载前调用,避免切商品后旧状态残留)
+    resetRelatedState: function() {
+      this.relatedArticlesPage = 1
+      this.relatedArticlesLoading = false
+      this.relatedArticlesNoMore = false
+      this.relatedProductsPage = 1
+      this.relatedProductsLoading = false
+      this.relatedProductsNoMore = false
+    },
+    // 加载商品关联文章(后端按商品标签匹配,共享标签数降序),每页 3 篇,触底翻页
+    loadRelatedArticles: function(productId, page, append) {
+      var self = this
+      if (!productId) return
+      var pg = page || 1
+      if (pg === 1) {
+        this.relatedArticles = []
+        this.relatedArticlesNoMore = false
+      }
+      if (this.relatedArticlesLoading) return
+      this.relatedArticlesLoading = true
+      getRelatedArticlesByProduct({ productId: productId, size: 3, page: pg }).then(function(res) {
+        self.relatedArticlesLoading = false
+        if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
+          if (append && pg > 1) {
+            self.relatedArticles = self.relatedArticles.concat(res.data)
+          } else {
+            self.relatedArticles = res.data
+          }
+          if (res.data.length < 3) self.relatedArticlesNoMore = true
+        } else {
+          self.relatedArticlesNoMore = true
+        }
+      }).catch(function() {
+        self.relatedArticlesLoading = false
+        self.relatedArticlesNoMore = true
+      })
+    },
+    // 加载商品关联商品(后端按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序),每页 4 个,触底翻页
+    loadRelatedProducts: function(productId, page, append) {
+      var self = this
+      if (!productId) return
+      var pg = page || 1
+      if (pg === 1) {
+        this.relatedProducts = []
+        this.relatedProductsNoMore = false
+      }
+      if (this.relatedProductsLoading) return
+      this.relatedProductsLoading = true
+      getRelatedProductsByProduct({ productId: productId, size: 4, page: pg }).then(function(res) {
+        self.relatedProductsLoading = false
+        if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
+          if (append && pg > 1) {
+            self.relatedProducts = self.relatedProducts.concat(res.data)
+          } else {
+            self.relatedProducts = res.data
+          }
+          if (res.data.length < 4) self.relatedProductsNoMore = true
+        } else {
+          self.relatedProductsNoMore = true
+        }
+      }).catch(function() {
+        self.relatedProductsLoading = false
+        self.relatedProductsNoMore = true
+      })
+    },
+    // 跳转相关文章详情
+    goRelatedArticle: function(item) {
+      if (!item || !item.id) return
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + item.id })
+    },
+    // 跳转相关商品详情(优先使用后端返回的 url)
+    goRelatedProduct: function(prod) {
+      if (!prod) return
+      var pid = prod.productId || prod.id
+      var url = prod.url || ('/pages/discover-detail/product-detail/product-detail?id=' + (pid || ''))
+      if (url.indexOf('/pages/') !== 0) return
+      uni.navigateTo({ url: url })
+    },
+    onScrollToBottom: function() {
+      // 触底优先翻页「相关商品」(页面下方区块),商品加载完再翻「相关文章」
+      if (this.relatedProducts.length > 0 && !this.relatedProductsNoMore && !this.relatedProductsLoading) {
+        this.relatedProductsPage++
+        this.loadRelatedProducts(this.product.id, this.relatedProductsPage, true)
+        return
+      }
+      if (this.relatedArticles.length > 0 && !this.relatedArticlesNoMore && !this.relatedArticlesLoading) {
+        this.relatedArticlesPage++
+        this.loadRelatedArticles(this.product.id, this.relatedArticlesPage, true)
+      }
+    },
     onSwiperImageError: function(idx) {
       // 单张图片加载失败时,替换为占位图
       var images = this.displayImages
@@ -397,6 +535,10 @@ export default {
             '/pages/discover-detail/product-detail/product-detail?id=' + that.product.id
           )
           that.loadSkuData(id)
+          // 关联区块:放在 loadDetail 内只走一次;先重置分页状态再加载第 1 页(文章在上、商品在下)
+          that.resetRelatedState()
+          that.loadRelatedArticles(id, 1, false)
+          that.loadRelatedProducts(id, 1, false)
         }
       }).catch(function() {
         that.loading = false
@@ -841,6 +983,20 @@ export default {
 .bundle-item-name { font-size: calc(28rpx * var(--font-scale, 1)); color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .bundle-item-spec { font-size: calc(24rpx * var(--font-scale, 1)); color: #999; margin-top: 4rpx; }
 .bundle-item-qty { font-size: 30rpx; font-weight: bold; color: #F97316; margin-left: 16rpx; flex-shrink: 0; }
+/* 关联区块(相关文章 + 相关商品,商城样式) */
+.related-section { margin: 20rpx; }
+.related-header { display: flex; align-items: baseline; margin-bottom: 16rpx; padding: 0 4rpx; }
+.related-title { font-size: 30rpx; font-weight: bold; color: #333; }
+.related-subtitle { font-size: 20rpx; color: #999; margin-left: 12rpx; }
+.related-loading-hint { text-align: center; font-size: 22rpx; color: #F97316; padding: 16rpx 0 8rpx; }
+.related-loading-hint.dim { color: #bbb; }
+.ra-list { display: flex; flex-direction: column; gap: 16rpx; background: #fff; border-radius: 16rpx; padding: 8rpx 24rpx 24rpx; }
+.ra-item { display: flex; align-items: center; background: #fafafa; border-radius: 12rpx; padding: 16rpx; margin-top: 16rpx; }
+.ra-item-pressed { opacity: 0.7; }
+.ra-cover { width: 140rpx; height: 100rpx; border-radius: 8rpx; background: #eee; flex-shrink: 0; }
+.ra-body { margin-left: 20rpx; flex: 1; min-width: 0; }
+.ra-title { display: block; font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 600; color: #333; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ra-summary { display: block; margin-top: 8rpx; font-size: 22rpx; color: #999; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 /* SKU 选择器 */
 .sku-section {
   background: #fff;

+ 2 - 0
cfc-frontend/utils/api.js

@@ -1960,6 +1960,8 @@ export const getArticleDetail = (data) => request('/api/articles/detail', 'POST'
 export const getArticleAudio = (data) => request('/api/articles/audio', 'POST', data)
 export const getArticleRelatedProducts = (data) => request('/api/articles/related-products', 'POST', data)
 export const getRelatedArticles = (data) => request('/api/articles/related', 'POST', data)
+export const getRelatedArticlesByProduct = (data) => request('/api/articles/related-by-product', 'POST', data)
+export const getRelatedProductsByProduct = (data) => request('/api/product/related-products', 'POST', data)
 export const getFeaturedArticles = (data) => request('/api/articles/featured', 'POST', data)
 export const recordArticleRead = (data) => request('/api/articles/record-read', 'POST', data)
 export const reportReadingTime = (data) => request('/api/articles/report-reading-time', 'POST', data)