DimensionProducts.vue 9.1 KB

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