DimensionProducts.vue 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <template>
  2. <view>
  3. <view v-if="!loading && displayProducts.length === 0" class="empty-state">
  4. <text class="empty-tip">{{ emptyText }}</text>
  5. </view>
  6. <view v-else :class="['product-list', layout === 'mall' ? 'product-list-mall' : '']">
  7. <view :class="['product-card', layout === 'mall' ? 'product-card-mall' : '']" v-for="prod in displayProducts" :key="prod.id" @click="onProductClick(prod)">
  8. <image class="product-img" :class="layout === 'mall' ? 'product-img-mall' : ''" :src="getImageUrl(prod.coverImage) || '/static/default-product.png'" mode="aspectFill" lazy-load />
  9. <view class="product-body" :class="layout === 'mall' ? 'product-body-mall' : ''">
  10. <text class="product-name" :class="layout === 'mall' ? 'product-name-mall' : ''">{{ prod.name }}</text>
  11. <view class="price-row">
  12. <text class="product-price" :class="layout === 'mall' ? 'product-price-mall' : ''" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
  13. <text class="product-price" :class="layout === 'mall' ? 'product-price-mall' : ''" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
  14. <text class="product-price" :class="layout === 'mall' ? 'product-price-mall' : ''" v-else>免费</text>
  15. </view>
  16. </view>
  17. </view>
  18. <view v-if="loading" class="loading-state">
  19. <text class="loading-text">加载中...</text>
  20. </view>
  21. <view v-if="loadingMore" class="loading-more">
  22. <text class="loading-text">加载中...</text>
  23. </view>
  24. <view v-if="noMore && displayProducts.length > 0" class="no-more">
  25. <text class="no-more-text">— 没有更多了 —</text>
  26. </view>
  27. </view>
  28. </view>
  29. </template>
  30. <script>
  31. import { getDimensionProducts, clickRepurchaseReminder } from '../utils/api.js'
  32. import config from '@/config.js'
  33. export default {
  34. props: {
  35. // 兼容模式:父组件直接传入商品数组(member-detail 等页面)
  36. products: { type: Array, default: function() { return [] } },
  37. // 自加载模式:传入 familyId 后组件内部调用 getDimensionProducts
  38. familyId: { type: [Number, String], default: null },
  39. isLoggedIn: { type: Boolean, default: false },
  40. dimensionCode: { type: String, default: '' },
  41. title: { type: String, default: '推荐商品' },
  42. showMore: { type: Boolean, default: true },
  43. // 'compact' 保持原始 4 列小卡片(member-detail 等页面);'mall' 两列大卡片(首页)
  44. layout: { type: String, default: 'compact' }
  45. },
  46. data: function() {
  47. return {
  48. selfProducts: [],
  49. loading: false,
  50. loadingMore: false,
  51. page: 1,
  52. size: 4,
  53. noMore: false
  54. }
  55. },
  56. computed: {
  57. displayProducts: function() {
  58. // 兼容模式:父组件传入 products 时原样展示(member-detail 等页面已自行截断)
  59. return (this.products && this.products.length > 0 ? this.products : this.selfProducts)
  60. },
  61. emptyText: function() {
  62. // 有 familyId(已登录且选中孩子)但推荐接口无数据
  63. if (this.familyId) {
  64. return '暂无推荐商品'
  65. }
  66. return this.isLoggedIn ? '暂无商品' : '登录后查看更多商品'
  67. }
  68. },
  69. mounted: function() {
  70. // 自加载模式:已登录且父组件未传入 products 时才请求推荐接口
  71. // (familyId 由后端从 JWT 派生,前端无需传入)
  72. if (this.isLoggedIn && (!this.products || this.products.length === 0)) {
  73. this.loadDimensionProducts()
  74. }
  75. },
  76. watch: {
  77. familyId: function() {
  78. // 切换孩子/登录态变化时重新加载
  79. if (this.isLoggedIn && (!this.products || this.products.length === 0)) {
  80. this.loadDimensionProducts()
  81. }
  82. },
  83. isLoggedIn: function(val) {
  84. // 登录后触发加载
  85. if (val && (!this.products || this.products.length === 0)) {
  86. this.loadDimensionProducts()
  87. }
  88. }
  89. },
  90. methods: {
  91. loadDimensionProducts: function() {
  92. var self = this
  93. self.page = 1
  94. self.noMore = false
  95. self.loading = true
  96. self.loadDimensionProductsByPage(self.page).then(function(list) {
  97. self.loading = false
  98. self.selfProducts = list
  99. if (list.length < self.size) self.noMore = true
  100. }).catch(function() {
  101. self.loading = false
  102. self.selfProducts = []
  103. })
  104. },
  105. // 供父页面 onReachBottom / scrolltolower 调用的加载更多
  106. loadMore: function() {
  107. var self = this
  108. if (self.loading || self.loadingMore || self.noMore) return
  109. // 兼容模式(父组件传入 products)不自加载
  110. if (self.products && self.products.length > 0) return
  111. self.loadingMore = true
  112. var nextPage = self.page + 1
  113. self.loadDimensionProductsByPage(nextPage).then(function(list) {
  114. self.loadingMore = false
  115. self.page = nextPage
  116. self.selfProducts = self.selfProducts.concat(list)
  117. if (list.length < self.size) self.noMore = true
  118. }).catch(function() {
  119. self.loadingMore = false
  120. })
  121. },
  122. loadDimensionProductsByPage: function(page) {
  123. var self = this
  124. var params = {
  125. page: page,
  126. size: self.size
  127. }
  128. if (self.dimensionCode) {
  129. params.dimensionCode = self.dimensionCode
  130. }
  131. return getDimensionProducts(params).then(function(res) {
  132. if (res.code === 200 && res.data) {
  133. return Array.isArray(res.data) ? res.data : (res.data.records || [])
  134. }
  135. return []
  136. })
  137. },
  138. onProductClick: function(prod) {
  139. if (!prod || !prod.id) return
  140. // 复购提醒追踪(推荐商品点击行为上报)
  141. if (prod.source === 'repurchase' && prod.id) {
  142. clickRepurchaseReminder(prod.id).catch(function() {})
  143. }
  144. this.$emit('productClick', prod)
  145. },
  146. formatPriceWithSymbol: function(price) {
  147. if (price == null) return '免费'
  148. return '¥' + (Number(price) / 100).toFixed(2)
  149. },
  150. getImageUrl: function(path) {
  151. if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
  152. var s = String(path).trim()
  153. if (!s || s === 'null' || s === '/null') return ''
  154. if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
  155. return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
  156. }
  157. }
  158. }
  159. </script>
  160. <stylescoped lang="scss">
  161. .product-list {
  162. display: flex;
  163. flex-wrap: wrap;
  164. }
  165. .product-card {
  166. width: calc(25% - 12rpx);
  167. background: $app-surface;
  168. border-radius: 12rpx;
  169. overflow: hidden;
  170. box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
  171. margin-right: 16rpx;
  172. margin-bottom: 16rpx;
  173. }
  174. .product-card:nth-child(4n) {
  175. margin-right: 0;
  176. }
  177. .product-img {
  178. width: 100%;
  179. height: 160rpx;
  180. background: $facade-fill;
  181. }
  182. .product-body {
  183. padding: 8rpx 10rpx 12rpx;
  184. }
  185. .product-name {
  186. display: block;
  187. font-size: 22rpx;
  188. color: $facade-text-strong;
  189. line-height: 1.4;
  190. margin-bottom: 4rpx;
  191. overflow: hidden;
  192. text-overflow: ellipsis;
  193. white-space: nowrap;
  194. }
  195. .price-row {
  196. display: flex;
  197. align-items: baseline;
  198. }
  199. .product-price {
  200. font-size: 24rpx;
  201. color: $brand-primary;
  202. font-weight: bold;
  203. }
  204. /* ===== 商城样式(首页 layout="mall")===== */
  205. .product-list-mall {
  206. justify-content: space-between;
  207. }
  208. .product-card-mall {
  209. width: 49%;
  210. border-radius: 16rpx;
  211. margin-right: 0;
  212. }
  213. .product-img-mall {
  214. height: 320rpx;
  215. }
  216. .product-body-mall {
  217. padding: 16rpx 16rpx 14rpx;
  218. }
  219. .product-name-mall {
  220. display: -webkit-box;
  221. font-size: 26rpx;
  222. line-height: 1.4;
  223. margin-bottom: 8rpx;
  224. text-overflow: ellipsis;
  225. -webkit-line-clamp: 2;
  226. -webkit-box-orient: vertical;
  227. white-space: normal;
  228. }
  229. .product-price-mall {
  230. font-size: 28rpx;
  231. }
  232. .empty-state {
  233. display: flex;
  234. justify-content: center;
  235. padding: 40rpx 0;
  236. }
  237. .empty-tip {
  238. font-size: 24rpx;
  239. color: $facade-text-muted;
  240. }
  241. .loading-state {
  242. display: flex;
  243. justify-content: center;
  244. padding: 40rpx 0;
  245. width: 100%;
  246. }
  247. .loading-more {
  248. display: flex;
  249. justify-content: center;
  250. padding: 20rpx 0;
  251. width: 100%;
  252. }
  253. .loading-text {
  254. font-size: 24rpx;
  255. color: $facade-text-muted;
  256. }
  257. .no-more {
  258. display: flex;
  259. justify-content: center;
  260. padding: 20rpx 0;
  261. width: 100%;
  262. }
  263. .no-more-text {
  264. font-size: 22rpx;
  265. color: $facade-text-ghost;
  266. }
  267. </style>