Browse Source

feat(content): 详情页关联内容区块组件化(按标签跨类型推荐)

- 新增 POST /api/content/related-items:以活动/文章/商品任一为源,按共享标签一次返回
  相关文章/活动/商品三组数据(分页 + 游客白名单)
- 新增 ContentRelatedService 聚合既有单类标签匹配逻辑(文章/活动/商品排序口径保持一致)
- ActivityService/ArticleService/ProductRecommendationService 补充公开的按标签匹配入口
- 前端新增 components/RelatedContent.vue 复用组件(自加载/自翻页,触底委托 loadMore)
- 活动/文章/商品详情页统一接入关联区块,移除各页内联相关实现
- 记录 API_REFERENCE.md
Sisyphus 5 days ago
parent
commit
448be97e47

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

@@ -62,6 +62,7 @@ private static final String[] PUBLIC_PATHS = {
          "/api/articles/related-products",
         "/api/articles/related",
         "/api/articles/related-by-product",
+        "/api/content/related-items",
          "/api/growth/**",
          "/api/activity/list",
          "/api/activity/detail",

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ContentRelatedController.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.controller.content;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ContentRelatedService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 内容关联推荐:活动/文章/商品任意一类为源,按共享标签一次返回「相关文章 / 相关活动 / 相关商品」三组数据。
+ * 游客可访问(白名单),未登录无家庭可见性/个性化权重加成。
+ */
+@Tag(name = "内容关联", description = "按标签跨类型匹配相关文章/活动/商品")
+@RestController
+@RequestMapping("/api/content")
+public class ContentRelatedController {
+
+    @Resource
+    private ContentRelatedService contentRelatedService;
+
+    @Operation(summary = "内容关联推荐:按源内容标签匹配相关文章/活动/商品(三源一次返回,支持分页)")
+    @PostMapping("/related-items")
+    public Result<Map<String, Object>> relatedItems(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute(value = "userId", required = false) Long userId,
+                                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        String sourceType = (String) params.get("sourceType");
+        Long sourceId = ParamUtils.getLong(params.get("sourceId"));
+        if (sourceType == null || sourceType.trim().isEmpty() || sourceId == null) {
+            return Result.error("sourceType/sourceId不能为空");
+        }
+        int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 4;
+        if (size < 1) size = 4;
+        if (size > 20) size = 20;
+        int page = params.get("page") != null ? Math.max(1, Integer.parseInt(params.get("page").toString())) : 1;
+
+        return Result.success(contentRelatedService.getRelated(sourceType.trim().toLowerCase(), sourceId, size, page, userId, currentMemberId));
+    }
+}

+ 102 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -23,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -410,6 +412,106 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         }
     }
 
+    /**
+     * 相关活动:按标签匹配(共享标签数降序 → 开始时间升序,与 {@link #list} 排序一致),
+     * 排除自身,仅取已发布。游客(userId 为空)仅返回公开活动(与 list 的游客策略一致)。
+     * 支持分页窗口截取。
+     *
+     * @param tags             源标签集合(来自文章/活动/商品)
+     * @param excludeActivityId 需排除的活动ID(活动源排除自身;其他源为 null)
+     * @param size             每页条数
+     * @param page             页码(从1开始)
+     * @param userId           当前用户ID(可为空)
+     * @return 相关活动DTO列表,无匹配返回空列表
+     */
+    public List<ActivityDTO> getRelatedActivitiesByTags(List<String> tags, Long excludeActivityId,
+                                                        int size, int page, Long userId) {
+        if (tags == null || tags.isEmpty() || size < 1) {
+            return Collections.emptyList();
+        }
+        int p = page < 1 ? 1 : page;
+        boolean isGuest = (userId == null || userId <= 0);
+
+        LambdaQueryWrapper<Activity> wrapper = new LambdaQueryWrapper<Activity>()
+                .eq(Activity::getStatus, "published");
+        if (excludeActivityId != null) {
+            wrapper.ne(Activity::getId, excludeActivityId);
+        }
+        wrapper.and(w -> {
+            boolean first = true;
+            for (String tag : tags) {
+                if (first) {
+                    w.apply("FIND_IN_SET({0}, tags) > 0", tag);
+                    first = false;
+                } else {
+                    w.or().apply("FIND_IN_SET({0}, tags) > 0", tag);
+                }
+            }
+        });
+        // 游客策略与 list() 一致:只返回公开/全部/未设置可见性的活动
+        if (isGuest) {
+            wrapper.and(w -> w.eq(Activity::getVisibility, "public")
+                    .or().eq(Activity::getVisibility, "all")
+                    .or().isNull(Activity::getVisibility));
+        }
+        List<Activity> candidates = this.list(wrapper);
+
+        // 共享标签计数,剔除零共享(SQL 已限定至少一个标签命中,此处防御)
+        Map<Long, Integer> score = new HashMap<>();
+        List<Activity> matched = new ArrayList<>();
+        for (Activity a : candidates) {
+            if (excludeActivityId != null && a.getId() != null && a.getId().equals(excludeActivityId)) {
+                continue;
+            }
+            int s = 0;
+            if (a.getTags() != null) {
+                for (String t : a.getTags().split(",")) {
+                    if (tags.contains(t.trim())) {
+                        s++;
+                    }
+                }
+            }
+            if (s > 0) {
+                matched.add(a);
+                score.put(a.getId(), s);
+            }
+        }
+        matched.sort((x, y) -> {
+            int diff = score.getOrDefault(y.getId(), 0) - score.getOrDefault(x.getId(), 0);
+            if (diff != 0) {
+                return diff;
+            }
+            Date px = x.getStartTime();
+            Date py = y.getStartTime();
+            if (px == null && py == null) return 0;
+            if (px == null) return 1;
+            if (py == null) return -1;
+            return px.compareTo(py);
+        });
+
+        // 分页窗口截取
+        int from = (p - 1) * size;
+        if (from >= matched.size()) {
+            return Collections.emptyList();
+        }
+        int to = Math.min(from + size, matched.size());
+        List<Activity> pageList = new ArrayList<>(matched.subList(from, to));
+
+        // 会员价解析(与 list() 口径一致,失败静默降级为非会员价)
+        String memberLevel = null;
+        if (!isGuest) {
+            try {
+                memberLevel = membershipService.getMemberLevel(userId);
+            } catch (Exception e) {
+                // 会员查询失败不影响关联列表
+            }
+        }
+        final String level = memberLevel;
+        return pageList.stream()
+                .map(a -> ActivityDTO.from(a, isGuest, level))
+                .collect(Collectors.toList());
+    }
+
     private boolean hasCheckedIn(Long activityId, Long userId) {
         List<Long> childIds = registrationService.getChildIdsByUser(userId);
         if (childIds.isEmpty()) {

+ 20 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -217,7 +217,26 @@ public class ArticleService {
                 tags.add(tag);
             }
         }
-        return matchArticlesByTags(tags, articleId, size, page, userId, familyId, vendorType);
+        return getRelatedArticlesByTags(tags, articleId, size, page, userId, familyId, vendorType);
+    }
+
+    /**
+     * 相关文章(按源标签匹配,通用入口):供内容关联服务(活动/商品 ↔ 文章)复用,
+     * 语义与 {@link #getRelatedArticles} 一致:共享标签数降序 → 发布时间降序,仅取已发布,
+     * 经可见性过滤后分页窗口截取。excludeArticleId 为空表示不排除(源为其他表内容)。
+     *
+     * @param tags            源标签集合
+     * @param excludeArticleId 需排除的文章ID(文章源排除自身;其他源为 null)
+     * @param size            每页条数
+     * @param page            页码(从1开始)
+     * @param userId          当前用户ID(可为空)
+     * @param familyId        家庭ID(可为空,私有文章可见性判定用)
+     * @param vendorType      服务商类型(可为空)
+     * @return 相关文章列表,无匹配返回空列表
+     */
+    public List<Article> getRelatedArticlesByTags(List<String> tags, Long excludeArticleId, int size, int page,
+                                                  Long userId, Long familyId, String vendorType) {
+        return matchArticlesByTags(tags, excludeArticleId, size, page, userId, familyId, vendorType);
     }
 
     /**

+ 210 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContentRelatedService.java

@@ -0,0 +1,210 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ActivityDTO;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 内容关联(跨类型标签匹配)
+ * <p>
+ * 活动/文章/商品三类内容均带 {@code tags} 字段:以其中任意一类为源,按<b>共享标签</b>匹配另外两类 + 同类内容,
+ * 供小程序「相关文章 / 相关活动 / 相关商品」区块展示(三源一次返回,前端整块渲染)。
+ * <p>
+ * 排序口径与既有单类关联接口保持一致:
+ * <ul>
+ *   <li>文章:共享标签数降序 → 发布时间降序({@link ArticleService#getRelatedArticlesByTags})</li>
+ *   <li>活动:共享标签数降序 → 开始时间升序({@link ActivityService#getRelatedActivitiesByTags})</li>
+ *   <li>商品:标签匹配数 > 用户权重 > 家庭权重 > 销量({@link ProductRecommendationService#getRelatedProductsByTags})</li>
+ * </ul>
+ * 源内容无标签 / 无匹配时返回空数组,由前端整块隐藏。
+ */
+@Slf4j
+@Service
+public class ContentRelatedService {
+
+    /** 三类源内容标识 */
+    public static final String SOURCE_ARTICLE = "article";
+    public static final String SOURCE_ACTIVITY = "activity";
+    public static final String SOURCE_PRODUCT = "product";
+
+    @Resource
+    private ArticleService articleService;
+
+    @Resource
+    private ActivityService activityService;
+
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private ActivityMapper activityMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 取源内容的相关文章/活动/商品。
+     *
+     * @param sourceType 源类型: article/activity/product
+     * @param sourceId   源内容ID
+     * @param size       每类每页条数
+     * @param page       页码(从1开始)
+     * @param userId     当前用户ID(可为空,游客仅见公开内容)
+     * @param memberId   当前成员ID(可为空,用于商品个性化权重/画像兜底)
+     * @return {@code {articles: [...], activities: [...], products: [...]}},无匹配的类为空数组
+     */
+    public Map<String, Object> getRelated(String sourceType, Long sourceId, int size, int page,
+                                          Long userId, Long memberId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("articles", Collections.emptyList());
+        result.put("activities", Collections.emptyList());
+        result.put("products", Collections.emptyList());
+
+        if (sourceId == null || sourceType == null || sourceType.trim().isEmpty()) {
+            return result;
+        }
+        int p = Math.max(1, size);
+        int pg = Math.max(1, page);
+
+        // 用户上下文(游客 userId 为空 → 家庭/权重画像均不参与)
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        String type = sourceType.trim().toLowerCase();
+        List<Article> articles = Collections.emptyList();
+        List<ActivityDTO> activities = Collections.emptyList();
+        List<Map<String, Object>> products = Collections.emptyList();
+
+        if (SOURCE_ARTICLE.equals(type)) {
+            Article source = articleMapper.selectById(sourceId);
+            List<String> tags = parseTags(source != null ? source.getTags() : null);
+            if (!tags.isEmpty()) {
+                // 文章:排除自身;活动/商品与文章分属不同表,无需排除
+                articles = articleService.getRelatedArticlesByTags(tags, sourceId, p, pg, userId, familyId, vendorType);
+                activities = activityService.getRelatedActivitiesByTags(tags, null, p, pg, userId);
+                products = productRecommendationService.getArticleRelatedProductsFull(sourceId, userId, familyId, memberId, (pg - 1) * p + p);
+            }
+        } else if (SOURCE_ACTIVITY.equals(type)) {
+            Activity source = activityMapper.selectById(sourceId);
+            List<String> tags = parseTags(source != null ? source.getTags() : null);
+            if (!tags.isEmpty()) {
+                // 活动:排除自身;文章/商品无需排除
+                articles = articleService.getRelatedArticlesByTags(tags, null, p, pg, userId, familyId, vendorType);
+                activities = activityService.getRelatedActivitiesByTags(tags, sourceId, p, pg, userId);
+                products = productRecommendationService.getRelatedProductsByTags(tags, null, userId, familyId, (pg - 1) * p + p, "活动", "activity_tag");
+            }
+        } else if (SOURCE_PRODUCT.equals(type)) {
+            Product source = productMapper.selectById(sourceId);
+            List<String> tags = parseTags(source != null ? source.getTags() : null);
+            if (!tags.isEmpty()) {
+                // 商品:排除自身;文章/活动无需排除
+                articles = articleService.getRelatedArticlesByTags(tags, null, p, pg, userId, familyId, vendorType);
+                activities = activityService.getRelatedActivitiesByTags(tags, null, p, pg, userId);
+                products = productRecommendationService.getRelatedProductsByTags(tags, sourceId, userId, familyId, (pg - 1) * p + p, "商品", "product_tag");
+            }
+        }
+
+        result.put("articles", toArticleBriefs(articles));
+        result.put("activities", activities);
+        result.put("products", sliceProducts(products, p, pg));
+        return result;
+    }
+
+    /** 标签解析:兼容 JSON 数组字符串(["标签1","标签2"])与逗号分隔(标签1,标签2),去重保序 */
+    private List<String> parseTags(String raw) {
+        if (raw == null || raw.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        String trimmed = raw.trim();
+        List<String> tags = new ArrayList<>();
+        if (trimmed.startsWith("[")) {
+            try {
+                com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(trimmed);
+                if (arr == null) {
+                    return Collections.emptyList();
+                }
+                for (int i = 0; i < arr.size(); i++) {
+                    Object o = arr.get(i);
+                    String tag = o != null ? o.toString().trim() : "";
+                    if (!tag.isEmpty() && !tags.contains(tag)) {
+                        tags.add(tag);
+                    }
+                }
+                return tags;
+            } catch (Exception e) {
+                log.warn("解析内容标签失败: {}", e.getMessage());
+                return Collections.emptyList();
+            }
+        }
+        for (String t : trimmed.split("[,,、]")) {
+            String tag = t.trim();
+            if (!tag.isEmpty() && !tags.contains(tag)) {
+                tags.add(tag);
+            }
+        }
+        return tags;
+    }
+
+    /** 文章轻量化(不含 content 正文,避免三源合一响应体膨胀) */
+    private List<Map<String, Object>> toArticleBriefs(List<Article> articles) {
+        if (articles == null || articles.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> briefs = new ArrayList<>();
+        for (Article a : articles) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", a.getId());
+            m.put("title", a.getTitle());
+            m.put("summary", a.getSummary());
+            m.put("coverImage", a.getCoverImage());
+            m.put("author", a.getAuthor());
+            m.put("viewCount", a.getViewCount());
+            m.put("publishedAt", a.getPublishedAt());
+            briefs.add(m);
+        }
+        return briefs;
+    }
+
+    /**
+     * 商品候选分页窗口截取:候选按 (page-1)*size + size 拉取后切片,与 ProductController
+     * 「全量候选 → 按页切片」语义一致,保证翻页不重复、不遗漏。
+     */
+    private List<Map<String, Object>> sliceProducts(List<Map<String, Object>> all, int size, int page) {
+        if (all == null || all.isEmpty()) {
+            return Collections.emptyList();
+        }
+        int from = (page - 1) * size;
+        if (from >= all.size()) {
+            return Collections.emptyList();
+        }
+        return new ArrayList<>(all.subList(from, Math.min(from + size, all.size())));
+    }
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -902,6 +902,25 @@ public class ProductRecommendationService {
         return getTagMatchedProducts(tags, productId, userId, familyId, limit, "商品", "product_tag");
     }
 
+    /**
+     * 相关商品(按源标签匹配,通用入口):供内容关联服务(活动 ↔ 商品)复用,
+     * 语义与 {@link #getRelatedProductsByProductFull} 一致:标签匹配数 > 用户权重 > 家庭权重 > 销量。
+     *
+     * @param sourceTags       源标签集合(来自活动/商品等)
+     * @param excludeProductId 需排除的商品ID(商品源排除自身;其他源为 null)
+     * @param userId           当前用户ID(可为空,用于用户权重)
+     * @param familyId         家庭ID(可为空,用于家庭权重)
+     * @param limit            返回数量上限
+     * @param sourceDesc       来源描述(如"活动"),用于 reason 文案
+     * @param sourceCode       来源编码(如 activity_tag),写入 source 字段
+     * @return 关联商品列表(无匹配时返回空列表)
+     */
+    public List<Map<String, Object>> getRelatedProductsByTags(List<String> sourceTags, Long excludeProductId,
+                                                             Long userId, Long familyId, int limit,
+                                                             String sourceDesc, String sourceCode) {
+        return getTagMatchedProducts(sourceTags, excludeProductId, userId, familyId, limit, sourceDesc, sourceCode);
+    }
+
     /**
      * 标签匹配商品推荐核心逻辑:通过源标签(文章/商品)与商品标签匹配,按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序
      *

+ 431 - 0
cfc-frontend/components/RelatedContent.vue

@@ -0,0 +1,431 @@
+<template>
+  <view class="related-content">
+    <!-- 首屏加载提示 -->
+    <view v-if="!loaded && loading" class="related-loading-wrap">
+      <text class="related-loading-text">加载中...</text>
+    </view>
+
+    <!-- 三源关联内容(任一分类无匹配整块隐藏) -->
+    <block v-if="loaded">
+      <!-- 相关文章 -->
+      <view v-if="articles.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 articles"
+            :key="item.id"
+            class="ra-item"
+            hover-class="ra-item-pressed"
+            @click="goArticle(item)"
+          >
+            <image class="ra-cover" :src="articleCover(item)" mode="aspectFill" lazy-load />
+            <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="articlesLoading" class="related-loading-hint">加载中...</view>
+        <view v-else-if="!articlesHasMore" class="related-loading-hint dim">没有更多了</view>
+      </view>
+
+      <!-- 相关活动 -->
+      <view v-if="activities.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 activities"
+            :key="item.id"
+            class="ra-item"
+            hover-class="ra-item-pressed"
+            @click="goActivity(item)"
+          >
+            <image class="ra-cover" :src="activityCover(item)" mode="aspectFill" lazy-load />
+            <view class="ra-body">
+              <text class="ra-title">{{ item.title }}</text>
+              <text class="ra-summary" v-if="item.startTime">{{ formatActivityTime(item.startTime) }}</text>
+              <view class="ra-bottom">
+                <text class="ra-status" :class="'status-' + (item._cls || 'upcoming')">{{ item._txt || '待开始' }}</text>
+                <text class="ra-price" v-if="item.priceLabel">{{ item.priceLabel }}</text>
+                <text class="ra-price" v-else-if="item.price && item.price > 0">¥{{ formatPrice(item.price) }}</text>
+                <text class="ra-price-free" v-else>免费</text>
+              </view>
+            </view>
+          </view>
+        </view>
+        <view v-if="activitiesLoading" class="related-loading-hint">加载中...</view>
+        <view v-else-if="!activitiesHasMore" class="related-loading-hint dim">没有更多了</view>
+      </view>
+
+      <!-- 相关商品(复用商城两列大卡片样式) -->
+      <view v-if="products.length > 0" class="related-section">
+        <view class="related-header">
+          <text class="related-title">相关商品</text>
+          <text class="related-subtitle">根据标签推荐</text>
+        </view>
+        <dimension-products :products="products" layout="mall" @productClick="goProduct" />
+        <view v-if="productsLoading" class="related-loading-hint">加载中...</view>
+        <view v-else-if="!productsHasMore" class="related-loading-hint dim">没有更多了</view>
+      </view>
+    </block>
+  </view>
+</template>
+
+<script>
+import { getContentRelated } from '@/utils/api.js'
+import config from '@/config.js'
+import { parseDate } from '@/utils/format.js'
+import DimensionProducts from '@/components/DimensionProducts.vue'
+
+/**
+ * 内容关联区块:以活动/文章/商品任意一类为源,按共享标签展示「相关文章 / 相关活动 / 相关商品」。
+ * 数据由后端 /api/content/related-items 一次返回,本组件自加载、自渲染、自翻页。
+ * 父页面在 scroll-view 触底时调用 ref.loadMore() 实现分段加载。
+ *
+ * Props:
+ * - sourceType: 'article' | 'activity' | 'product'
+ * - sourceId:   源内容ID
+ */
+export default {
+  name: 'RelatedContent',
+  components: { DimensionProducts },
+  props: {
+    sourceType: { type: String, default: '' },
+    sourceId: { type: [String, Number], default: '' }
+  },
+  data: function() {
+    return {
+      size: 4,
+      loaded: false,
+      loading: false,
+      // 相关文章
+      articles: [],
+      articlesPage: 1,
+      articlesLoading: false,
+      articlesHasMore: true,
+      // 相关活动
+      activities: [],
+      activitiesPage: 1,
+      activitiesLoading: false,
+      activitiesHasMore: true,
+      // 相关商品
+      products: [],
+      productsPage: 1,
+      productsLoading: false,
+      productsHasMore: true
+    }
+  },
+  created: function() {
+    this.reload()
+  },
+  watch: {
+    sourceId: function() {
+      this.reload()
+    }
+  },
+  methods: {
+    reload: function() {
+      if (!this.sourceType || this.sourceId === '' || this.sourceId === null || this.sourceId === undefined) {
+        return
+      }
+      var self = this
+      this.loaded = false
+      this.loading = true
+      getContentRelated({ sourceType: this.sourceType, sourceId: this.sourceId, size: this.size, page: 1 }).then(function(res) {
+        self.loading = false
+        self.loaded = true
+        if (res && res.code === 200 && res.data) {
+          var d = res.data
+          self.articles = Array.isArray(d.articles) ? d.articles : []
+          self.activities = self.decorateActivities(Array.isArray(d.activities) ? d.activities : [])
+          self.products = Array.isArray(d.products) ? d.products : []
+          self.articlesPage = 1
+          self.activitiesPage = 1
+          self.productsPage = 1
+          self.articlesHasMore = self.articles.length >= self.size
+          self.activitiesHasMore = self.activities.length >= self.size
+          self.productsHasMore = self.products.length >= self.size
+        } else {
+          self.articles = []
+          self.activities = []
+          self.products = []
+          self.articlesHasMore = false
+          self.activitiesHasMore = false
+          self.productsHasMore = false
+        }
+      }).catch(function() {
+        self.loading = false
+        self.loaded = true
+        self.articles = []
+        self.activities = []
+        self.products = []
+        self.articlesHasMore = false
+        self.activitiesHasMore = false
+        self.productsHasMore = false
+      })
+    },
+    // 父页面 scroll-view 触底调用:按 DOM 顺序(文章→活动→商品)翻页尚未加载完的分类
+    loadMore: function() {
+      var self = this
+      if (!this.loaded || this.loading) return
+      if (this.articlesHasMore && !this.articlesLoading) {
+        this.articlesLoading = true
+        var nextArticlePage = this.articlesPage + 1
+        getContentRelated({ sourceType: this.sourceType, sourceId: this.sourceId, size: this.size, page: nextArticlePage }).then(function(res) {
+          self.articlesLoading = false
+          if (res && res.code === 200 && res.data && Array.isArray(res.data.articles)) {
+            var more = res.data.articles
+            self.articles = self.articles.concat(more)
+            self.articlesPage = nextArticlePage
+            if (more.length < self.size) self.articlesHasMore = false
+          } else {
+            self.articlesHasMore = false
+          }
+        }).catch(function() {
+          self.articlesLoading = false
+          self.articlesHasMore = false
+        })
+        return
+      }
+      if (this.activitiesHasMore && !this.activitiesLoading) {
+        this.activitiesLoading = true
+        var nextActivityPage = this.activitiesPage + 1
+        getContentRelated({ sourceType: this.sourceType, sourceId: this.sourceId, size: this.size, page: nextActivityPage }).then(function(res) {
+          self.activitiesLoading = false
+          if (res && res.code === 200 && res.data && Array.isArray(res.data.activities)) {
+            var moreActs = self.decorateActivities(res.data.activities)
+            self.activities = self.activities.concat(moreActs)
+            self.activitiesPage = nextActivityPage
+            if (moreActs.length < self.size) self.activitiesHasMore = false
+          } else {
+            self.activitiesHasMore = false
+          }
+        }).catch(function() {
+          self.activitiesLoading = false
+          self.activitiesHasMore = false
+        })
+        return
+      }
+      if (this.productsHasMore && !this.productsLoading) {
+        this.productsLoading = true
+        var nextProductPage = this.productsPage + 1
+        getContentRelated({ sourceType: this.sourceType, sourceId: this.sourceId, size: this.size, page: nextProductPage }).then(function(res) {
+          self.productsLoading = false
+          if (res && res.code === 200 && res.data && Array.isArray(res.data.products)) {
+            var moreProds = res.data.products
+            self.products = self.products.concat(moreProds)
+            self.productsPage = nextProductPage
+            if (moreProds.length < self.size) self.productsHasMore = false
+          } else {
+            self.productsHasMore = false
+          }
+        }).catch(function() {
+          self.productsLoading = false
+          self.productsHasMore = false
+        })
+      }
+    },
+    // 活动卡片展示态:已结束 / 进行中 / 待开始(活动列表页同口径)
+    decorateActivities: function(list) {
+      var self = this
+      return list.map(function(act) {
+        var item = Object.assign({}, act)
+        var now = new Date()
+        var endTime = parseDate(item.endTime)
+        if (item.status === 'ended' || (endTime && endTime < now)) {
+          item._cls = 'ended'
+          item._txt = '已结束'
+          return item
+        }
+        var startTime = parseDate(item.startTime)
+        if (startTime && startTime > now) {
+          item._cls = 'upcoming'
+          item._txt = '待开始'
+        } else {
+          item._cls = 'ongoing'
+          item._txt = '进行中'
+        }
+        return item
+      })
+    },
+    formatActivityTime: function(timeStr) {
+      var d = parseDate(timeStr)
+      if (!d) return ''
+      var month = d.getMonth() + 1
+      var day = d.getDate()
+      var hour = d.getHours()
+      var minute = d.getMinutes()
+      var hourStr = hour < 10 ? '0' + hour : hour
+      var minStr = minute < 10 ? '0' + minute : minute
+      return month + '月' + day + '日 ' + hourStr + ':' + minStr + ' 开始'
+    },
+    formatPrice: function(cents) {
+      if (cents === null || cents === undefined) return ''
+      return (cents / 100).toFixed(2)
+    },
+    articleCover: function(item) {
+      if (!item || !item.coverImage) return '/static/default-article.png'
+      return this.resolveUrl(item.coverImage)
+    },
+    activityCover: function(item) {
+      if (!item || !item.coverImage) return '/static/default-activity.png'
+      return this.resolveUrl(item.coverImage)
+    },
+    resolveUrl: function(path) {
+      var s = String(path || '').trim()
+      if (!s || s === 'null' || s === 'undefined') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0 || s.indexOf('/static/') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+    },
+    // 跳转相关文章详情
+    goArticle: function(item) {
+      if (!item || !item.id) return
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + item.id })
+    },
+    // 跳转相关活动详情
+    goActivity: function(item) {
+      if (!item || !item.id) return
+      uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + item.id })
+    },
+    // 跳转商品详情(优先使用后端返回的 url)
+    goProduct: 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 })
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.related-content {
+  margin-top: 30rpx;
+}
+.related-loading-wrap {
+  padding: 40rpx 30rpx;
+  display: flex;
+  justify-content: center;
+}
+.related-loading-text {
+  font-size: 26rpx;
+  color: $facade-text-gray-400;
+}
+.related-section {
+  background: $app-surface;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 30rpx;
+}
+.related-header {
+  display: flex;
+  align-items: baseline;
+  margin-bottom: 20rpx;
+}
+.related-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: $facade-text-gray-800;
+  margin-right: 12rpx;
+}
+.related-subtitle {
+  font-size: 22rpx;
+  color: $facade-text-gray-400;
+}
+.ra-list {
+  display: flex;
+  flex-direction: column;
+}
+.ra-item {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  padding: 14rpx 0;
+  border-radius: 12rpx;
+}
+.ra-item-pressed {
+  background: $facade-fill-gray-100;
+}
+.ra-cover {
+  width: 160rpx;
+  height: 120rpx;
+  border-radius: 12rpx;
+  background: $facade-fill-gray-100;
+  flex-shrink: 0;
+}
+.ra-body {
+  flex: 1;
+  min-width: 0;
+  margin-left: 16rpx;
+  display: flex;
+  flex-direction: column;
+}
+.ra-title {
+  font-size: 27rpx;
+  font-weight: 500;
+  color: $facade-text-gray-800;
+  line-height: 1.4;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+}
+.ra-summary {
+  font-size: 23rpx;
+  color: $facade-text-gray-400;
+  line-height: 1.4;
+  margin-top: 6rpx;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+}
+.ra-bottom {
+  display: flex;
+  align-items: center;
+  margin-top: 8rpx;
+}
+.ra-status {
+  font-size: 20rpx;
+  padding: 2rpx 12rpx;
+  border-radius: 20rpx;
+  margin-right: 12rpx;
+}
+.ra-status.upcoming {
+  background: $uni-primary-pale;
+  color: $brand-primary;
+}
+.ra-status.ongoing {
+  background: $status-success-soft-2;
+  color: #166534;
+}
+.ra-status.ended {
+  background: $facade-fill-2;
+  color: $facade-text-muted;
+}
+.ra-price {
+  font-size: 23rpx;
+  color: $status-danger;
+  font-weight: 600;
+}
+.ra-price-free {
+  font-size: 23rpx;
+  color: $facade-text-gray-400;
+}
+.related-loading-hint {
+  text-align: center;
+  font-size: 24rpx;
+  color: $facade-text-gray-400;
+  padding: 16rpx 0 8rpx;
+}
+.related-loading-hint.dim {
+  color: $facade-text-gray-300;
+}
+</style>

+ 12 - 2
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -16,7 +16,7 @@
     </view>
 
     <!-- 活动内容 -->
-    <scroll-view v-else-if="activity.id" scroll-y class="content">
+    <scroll-view v-else-if="activity.id" scroll-y class="content" @scrolltolower="onScrollToBottom">
       <!-- 封面图 -->
       <image
         v-if="activity.coverImage"
@@ -80,6 +80,9 @@
           <text class="refund-notice-icon">⚠️</text>
           <text class="refund-notice-text">本活动为付费活动,报名成功后不支持退费</text>
         </view>
+
+        <!-- 内容关联(相关文章 / 相关活动 / 相关商品,按标签匹配;无匹配整块隐藏,触底分页加载) -->
+        <related-content ref="relatedContent" source-type="activity" :source-id="activity.id" />
       </view>
 
     </scroll-view>
@@ -283,13 +286,14 @@ import config from '@/config.js'
 import shareMixin from '../../../components/share-mixin.js'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
+import RelatedContent from '@/components/RelatedContent.vue'
 import { backfillProfile } from '../../../utils/profile-backfill.js'
 import { parseDate } from '@/utils/format.js'
 import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 
 export default {
   mixins: [shareMixin],
-  components: { MpHtml, ContentSharePoster },
+  components: { MpHtml, ContentSharePoster, RelatedContent },
   computed: {
     contentTagStyle() {
       var scale = this.fontScale || 1
@@ -375,6 +379,12 @@ export default {
     tagList: function(tags) {
       return String(tags || '').split(/[,,、]/).map(function(t) { return t.trim() }).filter(function(t) { return !!t })
     },
+    // 触底加载更多:委托给 <related-content> 组件分页加载
+    onScrollToBottom: function() {
+      if (this.$refs.relatedContent) {
+        this.$refs.relatedContent.loadMore()
+      }
+    },
     // ===== 工具方法 =====
     getImageUrl: function(path) {
       if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''

+ 9 - 150
cfc-frontend/pages/article-center/article-detail.vue

@@ -41,40 +41,8 @@
         <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="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="getImageUrl(item.coverImage) || '/static/default-article.png'" mode="aspectFill"  lazy-load />
-              <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>
+        <!-- 内容关联(相关文章 / 相关活动 / 相关商品,按标签匹配;无匹配整块隐藏,触底分页加载) -->
+        <related-content ref="relatedContent" source-type="article" :source-id="articleId" />
 
         <!-- 评论区(已屏蔽) -->
         <view style="height: 200rpx;"></view>
@@ -139,25 +107,20 @@
 </template>
 
 <script>
-import { getArticleDetail, getArticleAudio, getArticleRelatedProducts, getRelatedArticles, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz, completeTask } from '@/utils/api.js'
+import { getArticleDetail, getArticleAudio, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz, completeTask } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
-import DimensionProducts from '@/components/DimensionProducts.vue'
+import RelatedContent from '@/components/RelatedContent.vue'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import config from '@/config.js'
 import { getFontScale } from '@/utils/fontScale.js'
 
 export default {
   mixins: [shareMixin],
-  components: { ContentSharePoster, DimensionProducts, MpHtml },
+  components: { ContentSharePoster, RelatedContent, MpHtml },
   data() {
     return {
       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,
@@ -244,92 +207,11 @@ 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 不重复请求,避免双发);先重置分页状态再加载第 1 页
-          this.resetRelatedState()
-          this.loadRelatedProducts(id, 1, false)
-          this.loadRelatedArticles(id, 1, false)
+          // 内容关联区块由 <related-content> 组件自加载(sourceId=articleId),无需在此触发
         } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
       } catch (e) { this.error = true; this.errorMsg = '加载失败' }
       finally { this.loading = false }
     },
-    // 重置关联区块分页状态(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
-      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)) {
-          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
-      })
-    },
-    // 加载相关文章(后端按共享标签数降序),每页 3 篇,触底翻页;无匹配返回空数组 → 前端整块隐藏
-    loadRelatedArticles: function(articleId, page, append) {
-      var self = this
-      if (!articleId) return
-      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)) {
-          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
-      })
-    },
-    // 跳转相关文章详情
-    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 })
-    },
     formatDate(d) { return d ? d.slice(0, 10) : '' },
     getImageUrl: function(path) {
       if (!path) return ''
@@ -364,15 +246,9 @@ export default {
       return best ? best.code : ''
     },
     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)
+      // 触底翻页内容关联区块(文章→活动→商品,按组件内 DOM 顺序)
+      if (this.$refs.relatedContent && this.$refs.relatedContent.loadMore) {
+        this.$refs.relatedContent.loadMore()
       }
     },
     startNewReadingSession: function() {
@@ -599,23 +475,6 @@ export default {
 .divider { height: 1rpx; background: $facade-hairline-2; margin: 24rpx 30rpx; }
 .detail-body { padding: 0 30rpx; font-size: calc(32rpx * var(--font-scale, 1)); color: #444; line-height: 1.8; }
 
-/* 关联商品推荐 */
-.related-section { margin-top: 20rpx; padding: 0 30rpx 8rpx; }
-.related-header { display: flex; align-items: baseline; margin-bottom: 16rpx; }
-.related-title { font-size: 30rpx; font-weight: bold; color: $facade-text-strong; }
-.related-subtitle { font-size: 20rpx; color: $facade-text-muted; margin-left: 12rpx; }
-.related-loading-hint { text-align: center; font-size: 22rpx; color: $brand-primary; padding: 16rpx 0 8rpx; }
-.related-loading-hint.dim { color: $facade-text-ghost-light; }
-
-/* 相关文章 */
-.ra-list { display: flex; flex-direction: column; gap: 16rpx; }
-.ra-item { display: flex; align-items: center; background: $facade-fill-ghost; border-radius: 12rpx; padding: 16rpx; }
-.ra-item-pressed { opacity: 0.7; }
-.ra-cover { width: 140rpx; height: 100rpx; border-radius: 8rpx; background: $facade-hairline-2; flex-shrink: 0; }
-.ra-body { margin-left: 20rpx; flex: 1; min-width: 0; }
-.ra-title { display: block; font-size: 28rpx; font-weight: 600; color: $facade-text-strong; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.ra-summary { display: block; margin-top: 8rpx; font-size: 22rpx; color: $facade-text-muted; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-
 .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: $app-surface; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
 .share-btn { height: 72rpx; padding: 0 30rpx; background: linear-gradient(135deg, $brand-primary, $brand-primary-light); border-radius: 36rpx; display: flex; align-items: center; justify-content: center; border: none; }
 .share-btn::after { border: none; }

+ 11 - 145
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -124,40 +124,8 @@
         </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>
+      <!-- 内容关联(相关文章 / 相关活动 / 相关商品,按标签匹配;无匹配整块隐藏,触底分页加载) -->
+      <related-content ref="relatedContent" source-type="product" :source-id="product.id" />
     </scroll-view>
     <view v-else class="empty-wrap">
       <text class="empty-text">商品不存在或已下架</text>
@@ -184,15 +152,15 @@
 
 <script>
 import config from '@/config.js'
-import { productDetail, productOrderCreate, productOrderPay, cartAdd, getShareQrCode, productSpecMap, getMyMembership, getRelatedArticlesByProduct, getRelatedProductsByProduct } from '@/utils/api.js'
+import { productDetail, productOrderCreate, productOrderPay, cartAdd, getShareQrCode, productSpecMap, getMyMembership } 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 RelatedContent from '@/components/RelatedContent.vue'
 import shareMixin from '../../../components/share-mixin.js'
 import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 
 export default {
-  components: { MpHtml, ContentSharePoster, DimensionProducts },
+  components: { MpHtml, ContentSharePoster, RelatedContent },
   mixins: [shareMixin],
   computed: {
     contentTagStyle() {
@@ -217,12 +185,7 @@ export default {
       // 会员身份(实时调 getMyMembership,FREE=非会员)
       isMember: false,
       memberLevel: 'FREE',
-      bundleItems: [],
-      // 关联区块(相关文章在上、相关商品在下,触底分页)
-      relatedArticles: [],
-      relatedProducts: [],
-      relatedArticlesPage: 1, relatedArticlesLoading: false, relatedArticlesNoMore: false,
-      relatedProductsPage: 1, relatedProductsLoading: false, relatedProductsNoMore: false
+      bundleItems: []
     }
   },
   computed: {
@@ -413,92 +376,12 @@ export default {
     },
     // 重置关联区块分页状态(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 })
+      // 内容关联区块由 <related-content> 组件自加载(sourceId=product.id),无需在此重置
     },
+    // 触底加载更多:委托给 <related-content> 组件分页加载
     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)
+      if (this.$refs.relatedContent) {
+        this.$refs.relatedContent.loadMore()
       }
     },
     onSwiperImageError: function(idx) {
@@ -535,10 +418,7 @@ 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)
+          // 内容关联区块由 <related-content> 组件自加载(sourceId=product.id),无需在此触发
         }
       }).catch(function() {
         that.loading = false
@@ -983,20 +863,6 @@ export default {
 .bundle-item-name { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-strong; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .bundle-item-spec { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-muted; margin-top: 4rpx; }
 .bundle-item-qty { font-size: 30rpx; font-weight: bold; color: $brand-primary; 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: $facade-text-strong; }
-.related-subtitle { font-size: 20rpx; color: $facade-text-muted; margin-left: 12rpx; }
-.related-loading-hint { text-align: center; font-size: 22rpx; color: $brand-primary; padding: 16rpx 0 8rpx; }
-.related-loading-hint.dim { color: $facade-text-ghost-light; }
-.ra-list { display: flex; flex-direction: column; gap: 16rpx; background: $app-surface; border-radius: 16rpx; padding: 8rpx 24rpx 24rpx; }
-.ra-item { display: flex; align-items: center; background: $facade-fill-ghost; border-radius: 12rpx; padding: 16rpx; margin-top: 16rpx; }
-.ra-item-pressed { opacity: 0.7; }
-.ra-cover { width: 140rpx; height: 100rpx; border-radius: 8rpx; background: $facade-hairline-2; 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: $facade-text-strong; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.ra-summary { display: block; margin-top: 8rpx; font-size: 22rpx; color: $facade-text-muted; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 /* SKU 选择器 */
 .sku-section {
   background: $app-surface;

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

@@ -3452,3 +3452,12 @@ export const revokeReportMemberPermission = (targetMemberId, granteeUserId) =>
 /** 查询当前用户可见成员ID集合(null=管理员全部可见) */
 export const getMyVisibleReportMembers = () =>
   request('/api/health/report/member-permission/visible-members', 'POST', {})
+
+// ===================== 内容关联(活动/文章/商品 ↔ 三源标签匹配)=====================
+/**
+ * 内容关联推荐:以活动/文章/商品任意一类为源,一次返回相关文章/活动/商品。
+ * POST /api/content/related-items
+ * @param {{sourceType: string, sourceId: number|string, size?: number, page?: number}} data
+ * @returns {{articles: Array, activities: Array, products: Array}}
+ */
+export const getContentRelated = (data) => request('/api/content/related-items', 'POST', data || {})

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

@@ -737,6 +737,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/articles/quiz/submit` | 提交测验 |
 | `POST /api/content-sections/visible` | 可见区块 |
 | `POST /api/content-sections/list` | 区块列表 |
+| `POST /api/content/related-items` | 内容关联推荐:以活动/文章/商品任一为源(`sourceType`+`sourceId`),按共享标签一次返回 `data.articles / data.activities / data.products` 三组(支持 `size`/`page` 分页,游客可访问) |
 
 ### 4.13 管理员接口(`/api/admin/*`)