AdBanner.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. <template>
  2. <view class="ad-banner">
  3. <!-- Empty state when no data -->
  4. <view v-if="!hasItems" class="ad-banner-empty">
  5. <text class="ad-banner-empty-icon">📢</text>
  6. <text class="ad-banner-empty-text">暂无推荐内容</text>
  7. </view>
  8. <!-- Swiper carousel -->
  9. <swiper
  10. v-else
  11. class="ad-banner-swiper"
  12. :indicator-dots="totalSlides > 1"
  13. :active-index="activeIndex"
  14. @change="onSwiperChange"
  15. autoplay
  16. :interval="autoPlayInterval"
  17. :duration="swiperDuration"
  18. circular
  19. @click="onSlideClick"
  20. >
  21. <swiper-item
  22. v-for="(item, index) in displayItems"
  23. :key="item.id"
  24. :data-index="index"
  25. >
  26. <view
  27. class="ad-banner-slide"
  28. :data-index="index"
  29. @click="onSlideClick(index)"
  30. >
  31. <!-- 封面图 -->
  32. <image
  33. class="ad-banner-cover"
  34. :src="getCoverImage(item)"
  35. mode="aspectFill"
  36. />
  37. <!-- 叠加层:标签 + 标题 -->
  38. <view class="ad-banner-overlay">
  39. <!-- 标签 -->
  40. <view
  41. class="ad-banner-tag"
  42. :class="item.type === 'product' ? 'tag-product' : 'tag-activity'"
  43. >
  44. {{ item.type === 'product' ? '🛍️ 商品' : '🔥 活动' }}
  45. </view>
  46. <!-- 标题 -->
  47. <view class="ad-banner-title" v-html="getTitleHtml(item)" />
  48. </view>
  49. </view>
  50. </swiper-item>
  51. </swiper>
  52. <!-- 指示点 -->
  53. <view v-if="totalSlides > 1" class="ad-banner-dots">
  54. <view
  55. class="ad-banner-dot"
  56. v-for="(dot, i) in totalSlides"
  57. :key="i"
  58. :class="{ active: activeIndex === i }"
  59. ></view>
  60. </view>
  61. </view>
  62. </template>
  63. <script>
  64. import { parseDate } from '@/utils/format.js'
  65. import { getHomeBanners } from '@/utils/api.js'
  66. export default {
  67. props: {
  68. /** 推荐活动列表(后备数据) */
  69. activities: {
  70. type: Array,
  71. default: function() { return [] }
  72. },
  73. /** 推荐商品列表(后备数据) */
  74. products: {
  75. type: Array,
  76. default: function() { return [] }
  77. },
  78. /** 自动播放间隔(毫秒) */
  79. autoPlayInterval: {
  80. type: Number,
  81. default: 3000
  82. },
  83. /** Swiper 切换动画时长(毫秒) */
  84. swiperDuration: {
  85. type: Number,
  86. default: 500
  87. }
  88. },
  89. data() {
  90. return {
  91. // 合并后的项目列表,交替排列:商品、活动、商品、活动...
  92. displayItems: [],
  93. activeIndex: 0,
  94. totalSlides: 0,
  95. loading: false
  96. }
  97. },
  98. computed: {
  99. /** 是否有内容 */
  100. hasItems: function() {
  101. return this.displayItems && this.displayItems.length > 0
  102. }
  103. },
  104. mounted: function() {
  105. this.loadBanners()
  106. },
  107. methods: {
  108. /** 优先从配置接口加载广告,失败则回退到 props */
  109. loadBanners: function() {
  110. var self = this
  111. self.loading = true
  112. getHomeBanners().then(function(res) {
  113. self.loading = false
  114. if (res && res.code === 200 && res.data && res.data.length > 0) {
  115. self.fromConfig(res.data)
  116. } else {
  117. self.fromProps()
  118. }
  119. }).catch(function() {
  120. self.loading = false
  121. self.fromProps()
  122. })
  123. },
  124. /** 使用配置接口返回的数据构建 displayItems */
  125. fromConfig: function(banners) {
  126. var items = banners.map(function(b) {
  127. return {
  128. id: b.id,
  129. type: b.bannerType === 'product' ? 'product' : 'activity',
  130. name: b.title,
  131. title: b.title,
  132. coverImage: b.coverImage || b.cover_image,
  133. refId: b.refId || b.ref_id,
  134. linkUrl: b.linkUrl || b.link_url,
  135. source: b
  136. }
  137. }.bind(this))
  138. this.displayItems = items
  139. this.totalSlides = items.length
  140. this.activeIndex = 0
  141. },
  142. /** 使用 props(activities + products)构建 displayItems(后备逻辑) */
  143. fromProps: function() {
  144. this.mergeItems()
  145. },
  146. /** 获取合并后的项目列表(后备逻辑) */
  147. mergeItems: function() {
  148. var productItems = (this.products || []).slice(0, 4).map(function(p) {
  149. return {
  150. id: p.id || p._id,
  151. type: 'product',
  152. name: p.name || '未知商品',
  153. coverImage: p.coverImage || p.image,
  154. price: p.price,
  155. priceLabel: p.priceLabel,
  156. source: p
  157. }
  158. }.bind(this))
  159. var activityItems = (this.activities || []).slice(0, 4).map(function(a) {
  160. var startTime = a.startTime
  161. var d
  162. try {
  163. d = parseDate(startTime)
  164. if (d) {
  165. var month = d.getMonth() + 1
  166. var day = d.getDate()
  167. var hour = d.getHours() < 10 ? '0' + d.getHours() : d.getHours()
  168. var minute = d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes()
  169. startTime = month + '月' + day + '日 ' + hour + ':' + minute
  170. }
  171. } catch (e) {
  172. startTime = String(startTime)
  173. }
  174. return {
  175. id: a.id || a._id,
  176. type: 'activity',
  177. title: a.title || '未知活动',
  178. coverImage: a.coverImage || a.image,
  179. price: a.price,
  180. priceLabel: a.priceLabel,
  181. startTime: startTime,
  182. status: a.status,
  183. source: a
  184. }
  185. }.bind(this))
  186. // 交替合并:商品、活动、商品、活动...
  187. var merged = []
  188. var pi = 0, ai = 0
  189. var toggle = 0
  190. while ((pi < productItems.length || ai < activityItems.length) && merged.length < 8) {
  191. if (toggle % 2 === 0 && pi < productItems.length) {
  192. merged.push(productItems[pi++])
  193. } else if (ai < activityItems.length) {
  194. merged.push(activityItems[ai++])
  195. } else if (pi < productItems.length) {
  196. merged.push(productItems[pi++])
  197. }
  198. toggle++
  199. }
  200. this.displayItems = merged
  201. this.totalSlides = merged.length
  202. this.activeIndex = 0
  203. },
  204. /** 获取封面图片URL */
  205. getCoverImage: function(item) {
  206. var img = item.coverImage || item.image
  207. if (!img) return '/static/default-activity.png'
  208. var s = String(img).trim()
  209. if (!s || s === 'null' || s === '/null' || s === 'undefined') return '/static/default-activity.png'
  210. if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
  211. return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
  212. },
  213. /** 获取标题 HTML(防止 AI 腔,使用直陈) */
  214. getTitleHtml: function(item) {
  215. if (item.type === 'product') {
  216. return (item.name || '').substring(0, 12)
  217. } else {
  218. return (item.title || '').substring(0, 12)
  219. }
  220. },
  221. /** swiper 变化回调 */
  222. onSwiperChange: function(e) {
  223. this.activeIndex = e.detail.current
  224. },
  225. /** 点击轮播项 */
  226. onSlideClick: function(index) {
  227. if (isNaN(index) || index < 0 || index >= this.displayItems.length) return
  228. var item = this.displayItems[index]
  229. if (!item) return
  230. // 自定义链接(link_url)直接跳转,否则按类型路由
  231. if (item.linkUrl) {
  232. if (item.linkUrl.indexOf('http://') === 0 || item.linkUrl.indexOf('https://') === 0) {
  233. window.location.href = item.linkUrl
  234. return
  235. }
  236. uni.navigateTo({ url: item.linkUrl })
  237. return
  238. }
  239. if (item.type === 'product') {
  240. uni.navigateTo({
  241. url: '/pages/discover-detail/product-detail/product-detail?id=' + item.refId
  242. })
  243. } else if (item.type === 'activity') {
  244. uni.navigateTo({
  245. url: '/pages/activity/activity-detail/activity-detail?id=' + item.refId
  246. })
  247. }
  248. },
  249. },
  250. }
  251. </script>
  252. <style scoped>
  253. /* ===== 广告轮播组件 ===== */
  254. .ad-banner {
  255. width: 100%;
  256. margin: 0;
  257. padding: 0;
  258. overflow: hidden;
  259. }
  260. .ad-banner-empty {
  261. display: flex;
  262. flex-direction: column;
  263. align-items: center;
  264. padding: 60rpx 30rpx;
  265. color: #666;
  266. }
  267. .ad-banner-empty-icon {
  268. font-size: 48rpx;
  269. margin-bottom: 12rpx;
  270. }
  271. .ad-banner-empty-text {
  272. font-size: 24rpx;
  273. }
  274. .ad-banner-swiper {
  275. width: 100%;
  276. }
  277. /* 幻灯片卡片 */
  278. .ad-banner-slide {
  279. position: relative;
  280. width: 100%;
  281. /* 高度保持与封面图比例一致 */
  282. height: 320rpx;
  283. }
  284. /* 封面图 - 统一宽高比 16:9 -> 约320rpx高 */
  285. .ad-banner-cover {
  286. width: 100%;
  287. height: 100%;
  288. display: block;
  289. background: linear-gradient(135deg, #FF8C42 0%, #F59E0B 100%);
  290. border-radius: 16rpx;
  291. }
  292. /* 叠加层 */
  293. .ad-banner-overlay {
  294. position: absolute;
  295. bottom: 0;
  296. left: 0;
  297. right: 0;
  298. padding: 24rpx 24rpx 32rpx;
  299. }
  300. .ad-banner-tag {
  301. display: inline-block;
  302. padding: 4rpx 12rpx;
  303. border-radius: 20rpx;
  304. font-size: 20rpx;
  305. font-weight: 700;
  306. margin-bottom: 12rpx;
  307. text-transform: uppercase;
  308. letter-spacing: 1rpx;
  309. }
  310. .tag-product {
  311. background: rgba(255, 255, 255, 0.9);
  312. color: #F97316;
  313. }
  314. .tag-activity {
  315. background: rgba(255, 255, 255, 0.9);
  316. color: #EA580C;
  317. }
  318. /* 标题 - 使用 v-html 防止 AI 腔,直陈具体文本 */
  319. .ad-banner-title {
  320. font-size: 32rpx;
  321. font-weight: 700;
  322. color: #FFFFFF;
  323. text-shadow: 0 1rpx 4rpx rgba(0,0,0,0.4);
  324. line-height: 1.3;
  325. min-height: 48rpx;
  326. display: flex;
  327. align-items: center;
  328. -webkit-line-clamp: 2;
  329. -webkit-box-orient: vertical;
  330. overflow: hidden;
  331. }
  332. /* 指示点 */
  333. .ad-banner-dots {
  334. display: flex;
  335. justify-content: center;
  336. align-items: center;
  337. margin-top: 12rpx;
  338. gap: 8rpx;
  339. }
  340. .ad-banner-dot {
  341. width: 8rpx;
  342. height: 8rpx;
  343. border-radius: 50%;
  344. background: rgba(255,255,255,0.5);
  345. transition: all 0.3s ease;
  346. }
  347. .ad-banner-dot.active {
  348. width: 24rpx;
  349. background: #F97316;
  350. }
  351. </style>