Просмотр исходного кода

feat: 小程序文章详情新增相关文章(按标签,无匹配整块隐藏)

Xiaogang Liao 5 дней назад
Родитель
Сommit
09d46e6338

+ 20 - 0
cfc-frontend/__tests__/api/api.test.js

@@ -1233,3 +1233,23 @@ describe('invite card API', () => {
 })
 
 
+describe('article related API', () => {
+  test('getRelatedArticles sends POST /api/articles/related', async () => {
+    let captured = null
+    global.uni.request.mockImplementation((opts) => {
+      captured = opts
+      process.nextTick(() => { if (opts.success) opts.success({ data: { code: 200, data: [] } }) })
+    })
+    await api.getRelatedArticles({ articleId: 15, size: 3 })
+    expect(captured.url).toMatch(/\/api\/articles\/related$/)
+    expect(captured.method).toBe('POST')
+    expect(captured.data.articleId).toBe(15)
+    expect(captured.data.size).toBe(3)
+  })
+
+  test('getRelatedArticles passes data through request', async () => {
+    mockSuccess({ code: 200, data: [{ id: 2, title: 't' }] })
+    const res = await api.getRelatedArticles({ articleId: 15 })
+    expect(res.code).toBe(200)
+  })
+})

+ 51 - 1
cfc-frontend/pages/article-center/article-detail.vue

@@ -50,6 +50,29 @@
           <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>
+            <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" />
+              <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>
+
         <!-- 评论区(已屏蔽) -->
         <view style="height: 200rpx;"></view>
       </scroll-view>
@@ -109,7 +132,7 @@
 </template>
 
 <script>
-import { getArticleDetail, getArticleAudio, getArticleRelatedProducts, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz } from '@/utils/api.js'
+import { getArticleDetail, getArticleAudio, getArticleRelatedProducts, getRelatedArticles, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 import DimensionProducts from '@/components/DimensionProducts.vue'
@@ -124,6 +147,7 @@ export default {
     return {
       articleId: '', article: null, loading: true, error: false, errorMsg: '',
       relatedProducts: [],
+      relatedArticles: [],
       readingSeconds: 0, lastReportedSeconds: 0, readingCompleted: false,
       isTimerRunning: false, timerHandle: null, syncHandle: null,
       currentChildId: null, isLogin: false,
@@ -196,6 +220,8 @@ export default {
           this.startReadingTimer()
           // 关联商品:放在 loadDetail 内只走一次(onShow 不重复请求,避免双发)
           this.loadRelatedProducts(id)
+          // 相关文章:同样只走一次,无匹配时为空数组 → 模板整块隐藏
+          this.loadRelatedArticles(id)
         } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
       } catch (e) { this.error = true; this.errorMsg = '加载失败' }
       finally { this.loading = false }
@@ -210,6 +236,21 @@ export default {
         }
       }).catch(function() { self.relatedProducts = [] })
     },
+    // 加载相关文章(后端按共享标签数降序,无匹配返回空数组 → 前端整块隐藏)
+    loadRelatedArticles: function(articleId) {
+      var self = this
+      if (!articleId) return
+      getRelatedArticles({ articleId: articleId, size: 3 }).then(function(res) {
+        if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
+          self.relatedArticles = res.data
+        }
+      }).catch(function() { self.relatedArticles = [] })
+    },
+    // 跳转相关文章详情
+    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
@@ -471,6 +512,15 @@ export default {
 .related-title { font-size: 30rpx; font-weight: bold; color: #333; }
 .related-subtitle { font-size: 20rpx; color: #999; margin-left: 12rpx; }
 
+/* 相关文章 */
+.ra-list { display: flex; flex-direction: column; gap: 16rpx; }
+.ra-item { display: flex; align-items: center; background: #fafafa; border-radius: 12rpx; padding: 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: 28rpx; 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; }
+
 .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; 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, #F97316, #FB923C); border-radius: 36rpx; display: flex; align-items: center; justify-content: center; border: none; }
 .share-btn::after { border: none; }

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

@@ -1964,6 +1964,7 @@ export const getArticleList = (data) => request('/api/articles/list', 'POST', da
 export const getArticleDetail = (data) => request('/api/articles/detail', 'POST', data)
 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 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)