ArticleFeed.vue 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. <template>
  2. <view class="af-section">
  3. <!-- 空态 -->
  4. <view v-if="(!articles || articles.length === 0) && !loading" class="af-empty">
  5. <text class="af-empty-text">暂无推荐文章</text>
  6. </view>
  7. <!-- 加载态骨架:3 条左图右文卡片 -->
  8. <view v-else-if="loading && (!articles || articles.length === 0)" class="af-list">
  9. <view v-for="n in 3" :key="n" class="af-item">
  10. <view class="af-cover af-skeleton-block"></view>
  11. <view class="af-body">
  12. <view class="af-skeleton-line af-skeleton-line-title"></view>
  13. <view class="af-skeleton-line"></view>
  14. </view>
  15. </view>
  16. </view>
  17. <!-- 左图右文列表(与文章详情「相关文章」视觉一致) -->
  18. <view v-else class="af-list">
  19. <view
  20. v-for="item in articles"
  21. :key="item.id"
  22. class="af-item"
  23. hover-class="af-item-pressed"
  24. @click="onArticleClick(item)"
  25. >
  26. <image class="af-cover" :src="articleCover(item)" mode="aspectFill" lazy-load />
  27. <view class="af-body">
  28. <text class="af-title">{{ item.title }}</text>
  29. <text class="af-summary" v-if="item.summary">{{ item.summary }}</text>
  30. </view>
  31. </view>
  32. </view>
  33. </view>
  34. </template>
  35. <script>
  36. import config from '@/config.js'
  37. /**
  38. * ArticleFeed — 文章「关联文章」同款左图右文列表,用于首页/成长页「推荐阅读」统一视觉。
  39. * 纯展示组件:数据由父页面传入,点击通过 articleClick 事件上抛文章对象。
  40. */
  41. export default {
  42. name: 'ArticleFeed',
  43. props: {
  44. articles: { type: Array, default: function() { return [] } },
  45. loading: { type: Boolean, default: false }
  46. },
  47. methods: {
  48. onArticleClick: function(item) {
  49. this.$emit('articleClick', item)
  50. },
  51. // 封面图 URL 解析:空 / null / undefined / '/null' → 默认图(与 RelatedContent 同口径,增补 '/null' 兜底)
  52. articleCover: function(item) {
  53. if (!item || !item.coverImage) return '/static/default-article.png'
  54. return this.resolveUrl(item.coverImage) || '/static/default-article.png'
  55. },
  56. resolveUrl: function(path) {
  57. var s = String(path || '').trim()
  58. if (!s || s === 'null' || s === 'undefined' || s === '/null') return ''
  59. if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0 || s.indexOf('/static/') === 0) return s
  60. return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
  61. }
  62. }
  63. }
  64. </script>
  65. <style scoped lang="scss">
  66. .af-section {
  67. background: $app-surface;
  68. border-radius: 16rpx;
  69. padding: 24rpx;
  70. }
  71. /* ---- 空态 ---- */
  72. .af-empty {
  73. display: flex;
  74. justify-content: center;
  75. padding: 40rpx 0;
  76. }
  77. .af-empty-text {
  78. font-size: calc(24rpx * var(--font-scale, 1));
  79. color: $facade-text-gray-400;
  80. }
  81. /* ---- 列表 ---- */
  82. .af-list {
  83. display: flex;
  84. flex-direction: column;
  85. }
  86. .af-item {
  87. display: flex;
  88. flex-direction: row;
  89. align-items: flex-start;
  90. padding: 14rpx 0;
  91. border-radius: 16rpx;
  92. }
  93. .af-item-pressed {
  94. background: $facade-fill-gray-100;
  95. }
  96. .af-cover {
  97. width: 160rpx;
  98. height: 120rpx;
  99. border-radius: 16rpx;
  100. background: $facade-fill-gray-100;
  101. flex-shrink: 0;
  102. }
  103. .af-body {
  104. flex: 1;
  105. min-width: 0;
  106. margin-left: 16rpx;
  107. display: flex;
  108. flex-direction: column;
  109. }
  110. .af-title {
  111. font-size: calc(27rpx * var(--font-scale, 1));
  112. font-weight: 500;
  113. color: $facade-text-gray-800;
  114. line-height: 1.4;
  115. display: -webkit-box;
  116. -webkit-box-orient: vertical;
  117. -webkit-line-clamp: 2;
  118. overflow: hidden;
  119. }
  120. .af-summary {
  121. font-size: calc(23rpx * var(--font-scale, 1));
  122. color: $facade-text-gray-400;
  123. line-height: 1.4;
  124. margin-top: 6rpx;
  125. display: -webkit-box;
  126. -webkit-box-orient: vertical;
  127. -webkit-line-clamp: 2;
  128. overflow: hidden;
  129. }
  130. /* ---- 骨架屏(3 条左图右文占位) ---- */
  131. .af-skeleton-block {
  132. background: linear-gradient(180deg, $facade-fill-4 0%, $facade-fill 50%, $facade-fill-4 100%);
  133. background-size: 200% 100%;
  134. animation: af-skeleton-wave 1.4s ease-in-out infinite;
  135. }
  136. .af-skeleton-line {
  137. width: 100%;
  138. height: 28rpx;
  139. border-radius: 8rpx;
  140. margin-top: 10rpx;
  141. background: linear-gradient(180deg, $facade-fill-4 0%, $facade-fill 50%, $facade-fill-4 100%);
  142. background-size: 200% 100%;
  143. animation: af-skeleton-wave 1.4s ease-in-out infinite;
  144. }
  145. .af-skeleton-line-title {
  146. width: 72%;
  147. height: 32rpx;
  148. margin-top: 0;
  149. }
  150. @keyframes af-skeleton-wave {
  151. 0% { background-position: 200% 0; }
  152. 100% { background-position: -200% 0; }
  153. }
  154. </style>