国产化查询机
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

434 lines
12 KiB

7 months ago
  1. <template>
  2. <!-- 参考 https://blog.csdn.net/weixin_44198965/article/details/147831036 -->
  3. <div class="waterfall-container">
  4. <!-- 列容器 -->
  5. <div ref="columnWrapper" class="waterfall-column-wrapper">
  6. <!-- 瀑布流项 -->
  7. <div
  8. v-for="(item, index) in renderedList"
  9. :key="item.id || index"
  10. v-lazy-load="item.image || item.cover"
  11. class="waterfall-item"
  12. >
  13. <!-- 图片类型 -->
  14. <div v-if="item.type === 'image'" class="item-content" @click="jump(item.linkUrl)">
  15. <img
  16. :src="placeholder"
  17. :alt="item.title || '图片'"
  18. class="item-image"
  19. >
  20. <div v-if="item.title" class="item-title">{{ item.title }}</div>
  21. </div>
  22. <!-- 视频类型 -->
  23. <div v-else-if="item.type === 'video'" class="item-content">
  24. <video
  25. :poster="placeholder"
  26. controls
  27. class="item-video"
  28. >
  29. <source :src="item.videoUrl" type="video/mp4">
  30. </video>
  31. <div v-if="item.title" class="item-title">{{ item.title }}</div>
  32. </div>
  33. <!-- 图文类型 -->
  34. <div v-else class="item-content">
  35. <img
  36. v-if="item.image"
  37. :src="placeholder"
  38. :alt="item.title || '图文'"
  39. class="item-image"
  40. >
  41. <div v-if="item.title" class="item-title">{{ item.title }}</div>
  42. <div v-if="item.desc" class="item-desc">{{ item.desc }}</div>
  43. </div>
  44. </div>
  45. </div>
  46. <!-- 加载状态 -->
  47. <div v-if="loading" class="loading-more">
  48. 加载中...
  49. </div>
  50. <div v-if="isComplete && renderedList.length > 0" class="load-complete">
  51. 没有更多内容了
  52. </div>
  53. </div>
  54. </template>
  55. <script>
  56. export default {
  57. name: 'Waterfall',
  58. directives: {
  59. lazyLoad: {
  60. inserted(el, binding, vnode) {
  61. const observer = new IntersectionObserver((entries) => {
  62. entries.forEach(entry => {
  63. if (entry.isIntersecting) {
  64. const img = el.tagName === 'IMG' ? el : el.querySelector('img')
  65. if (img) {
  66. img.src = binding.value
  67. // 图片加载完成后重新布局
  68. img.onload = () => {
  69. vnode.context.layoutItems()
  70. }
  71. // 图片加载错误处理
  72. img.onerror = () => {
  73. if (vnode.context.placeholder) {
  74. img.src = vnode.context.placeholder
  75. }
  76. }
  77. }
  78. observer.unobserve(el)
  79. }
  80. })
  81. }, {
  82. rootMargin: '0px 0px 100px 0px' // 提前100px加载
  83. })
  84. observer.observe(el)
  85. el._observer = observer
  86. },
  87. unbind(el) {
  88. if (el._observer) {
  89. el._observer.disconnect()
  90. }
  91. }
  92. }
  93. },
  94. // 定义组件props
  95. props: {
  96. // 数据源数组
  97. list: {
  98. type: Array,
  99. required: true,
  100. default: () => []
  101. },
  102. // 列数配置
  103. columns: {
  104. type: Number,
  105. default: 2,
  106. validator: function(value) {
  107. return value >= 1 && value <= 5 // 限制列数在1-5之间
  108. }
  109. },
  110. // 列间距
  111. gap: {
  112. type: Number,
  113. default: 10
  114. },
  115. // 是否开启懒加载
  116. lazyLoad: {
  117. type: Boolean,
  118. default: true
  119. },
  120. // 触底加载的阈值(距离底部多少像素触发加载)
  121. loadThreshold: {
  122. type: Number,
  123. default: 100
  124. },
  125. // 图片加载时的占位图
  126. placeholder: {
  127. type: String,
  128. default: '改成你的图片'
  129. },
  130. totalItems: {
  131. type: Number,
  132. default: 0
  133. }
  134. },
  135. data() {
  136. return {
  137. waterfallRef: null, // 瀑布流容器引用
  138. columnHeights: [], // 每列当前高度数组
  139. columnWrapperRef: null, // 列容器引用
  140. loading: false, // 加载状态
  141. isComplete: false, // 是否已加载全部数据
  142. renderedList: [], // 已渲染的数据列表
  143. pageSize: 20, // 每次加载的数据量
  144. currentPage: 1 // 当前页码
  145. }
  146. },
  147. computed: {
  148. // 计算列宽度(根据容器宽度和列数计算)
  149. columnWidth() {
  150. if (!this.waterfallRef) return 0
  151. const containerWidth = this.waterfallRef.clientWidth
  152. return (containerWidth - (this.columns - 1) * this.gap) / this.columns
  153. }
  154. },
  155. watch: {
  156. // 监听列数变化
  157. columns(newVal, oldVal) {
  158. if (newVal !== oldVal) {
  159. this.resetLayout()
  160. }
  161. },
  162. // 监听数据源变化
  163. list: {
  164. handler(newList) {
  165. this.renderedList = newList // 直接同步父组件数据(无需分页切片)
  166. this.$nextTick(() => this.layoutItems())
  167. },
  168. deep: true
  169. }
  170. },
  171. mounted() {
  172. this.scrollContainer = this.$el
  173. this.waterfallRef = this.$el
  174. this.columnWrapperRef = this.$el.querySelector('.waterfall-column-wrapper')
  175. this.resetLayout()
  176. if (this.lazyLoad) {
  177. this.scrollContainer.addEventListener('scroll', this.handleScroll)
  178. }
  179. window.addEventListener('resize', this.handleResize)
  180. },
  181. beforeDestroy() {
  182. if (this.lazyLoad && this.scrollContainer) {
  183. this.scrollContainer.removeEventListener('scroll', this.handleScroll)
  184. }
  185. window.removeEventListener('resize', this.handleResize)
  186. },
  187. methods: {
  188. // 初始化列高度数组
  189. initColumnHeights() {
  190. this.columnHeights = new Array(this.columns).fill(0)
  191. },
  192. // 初始化渲染数据
  193. initRenderList() {
  194. this.renderedList = this.list.slice(0, this.pageSize)
  195. this.currentPage = 1
  196. this.isComplete = this.renderedList.length >= this.list.length
  197. },
  198. // 重置瀑布流布局
  199. resetLayout() {
  200. this.initColumnHeights()
  201. this.initRenderList()
  202. this.$nextTick(() => {
  203. this.layoutItems()
  204. })
  205. },
  206. // 瀑布流布局算法
  207. layoutItems() {
  208. if (!this.columnWrapperRef || !this.waterfallRef) return
  209. // 获取所有子元素
  210. const items = this.columnWrapperRef.children
  211. if (!items || items.length === 0) return
  212. // 初始化列高度
  213. this.initColumnHeights()
  214. // 临时列高度副本用于计算
  215. const tempColumnHeights = [...this.columnHeights]
  216. // 遍历所有子元素进行布局
  217. Array.from(items).forEach((item, index) => {
  218. // 找到当前最短的列
  219. const minHeight = Math.min(...tempColumnHeights)
  220. const columnIndex = tempColumnHeights.indexOf(minHeight)
  221. // 计算位置
  222. const left = columnIndex * (this.columnWidth + this.gap)
  223. const top = minHeight
  224. // 应用样式
  225. item.style.position = 'absolute'
  226. item.style.width = `${this.columnWidth}px`
  227. item.style.left = `${left}px`
  228. item.style.top = `${top}px`
  229. item.style.transition = 'all 0.3s ease'
  230. // 更新列高度
  231. tempColumnHeights[columnIndex] += item.clientHeight + this.gap
  232. })
  233. // 更新列高度
  234. this.columnHeights = tempColumnHeights
  235. // 设置容器高度
  236. const maxHeight = Math.max(...tempColumnHeights)
  237. this.columnWrapperRef.style.height = `${maxHeight}px`
  238. },
  239. // 滚动事件处理
  240. // handleScroll() {
  241. // if (this.loading || this.isComplete) return
  242. // const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
  243. // const windowHeight = window.innerHeight
  244. // const scrollHeight = document.documentElement.scrollHeight
  245. // // 判断是否触底
  246. // if (scrollTop + windowHeight >= scrollHeight - this.loadThreshold) {
  247. // this.loadMore()
  248. // }
  249. // },
  250. handleScroll() {
  251. if (this.loading || (this.totalItems > 0 && this.renderedList.length >= this.totalItems)) {
  252. return
  253. }
  254. // 获取滚动容器(父组件的.content-main)
  255. const scrollContainer = this.$parent.$el.querySelector('.content-main') || window
  256. let scrollTop, clientHeight, scrollHeight
  257. if (scrollContainer === window) {
  258. scrollTop = document.documentElement.scrollTop || document.body.scrollTop
  259. clientHeight = window.innerHeight
  260. scrollHeight = document.documentElement.scrollHeight
  261. } else {
  262. // 关键:用滚动容器的实际尺寸计算(避免父组件padding影响)
  263. scrollTop = scrollContainer.scrollTop
  264. clientHeight = scrollContainer.clientHeight
  265. scrollHeight = scrollContainer.scrollHeight
  266. }
  267. // 触底阈值:距离底部100px时触发加载(可调整)
  268. if (scrollTop + clientHeight >= scrollHeight - this.loadThreshold) {
  269. this.$emit('scroll-end') // 触发父组件的loadMoreData
  270. console.log('触发加载更多')
  271. }
  272. },
  273. // 加载更多数据
  274. loadMore() {
  275. if (this.loading || this.isComplete) return
  276. this.loading = true
  277. // 模拟异步加载
  278. setTimeout(() => {
  279. try {
  280. const nextPage = this.currentPage + 1
  281. const startIndex = (nextPage - 1) * this.pageSize
  282. const endIndex = nextPage * this.pageSize
  283. if (startIndex >= this.list.length) {
  284. this.isComplete = true
  285. return
  286. }
  287. const newItems = this.list.slice(startIndex, endIndex)
  288. this.renderedList = [...this.renderedList, ...newItems]
  289. this.currentPage = nextPage
  290. // 检查是否已加载全部数据
  291. if (endIndex >= this.list.length) {
  292. this.isComplete = true
  293. }
  294. // 等待DOM更新后重新布局
  295. this.$nextTick(() => {
  296. this.layoutItems()
  297. })
  298. } catch (error) {
  299. console.error('加载更多数据失败:', error)
  300. } finally {
  301. this.loading = false
  302. }
  303. }, 500)
  304. },
  305. jump(url) {
  306. window.location.href = url
  307. },
  308. // 响应式处理窗口大小变化
  309. handleResize() {
  310. this.resetLayout()
  311. }
  312. }
  313. }
  314. </script>
  315. <style scoped>
  316. .waterfall-container {
  317. position: relative;
  318. width: calc(100%);
  319. height: 800px;
  320. overflow: hidden;
  321. overflow-y: auto;
  322. }
  323. .waterfall-column-wrapper {
  324. position: relative;
  325. width: 100%;
  326. }
  327. .waterfall-item {
  328. margin-bottom: 10px;
  329. break-inside: avoid;
  330. box-sizing: border-box;
  331. background: #fff;
  332. border-radius: 8px;
  333. overflow: hidden;
  334. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  335. transition: all 0.3s ease;
  336. }
  337. .waterfall-item:hover {
  338. transform: translateY(-5px);
  339. box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
  340. }
  341. .item-content {
  342. padding: 12px;
  343. }
  344. .item-image, .item-video {
  345. width: 100%;
  346. border-radius: 4px;
  347. display: block;
  348. background: #f5f5f5;
  349. }
  350. .item-video {
  351. aspect-ratio: 9/16;
  352. object-fit: cover;
  353. }
  354. .item-title {
  355. margin-top: 8px;
  356. font-size: 24px;
  357. line-height: 1.4;
  358. color: #333;
  359. font-weight: bold;
  360. display: -webkit-box;
  361. -webkit-line-clamp: 2;
  362. -webkit-box-orient: vertical;
  363. overflow: hidden;
  364. }
  365. .item-desc {
  366. margin-top: 6px;
  367. font-size: 12px;
  368. color: #666;
  369. line-height: 1.4;
  370. display: -webkit-box;
  371. -webkit-line-clamp: 3;
  372. -webkit-box-orient: vertical;
  373. overflow: hidden;
  374. }
  375. .loading-more, .load-complete {
  376. text-align: center;
  377. padding: 20px 0;
  378. color: #999;
  379. font-size: 14px;
  380. }
  381. </style>