用这个页面为原型<template> <view :class="['app-container'...
Creado el: 27 de enero de 2026
Respondido usando GPT-5.2 Thinking por Chat01
Creado el: 27 de enero de 2026
Respondido usando GPT-5.2 Thinking por Chat01
用这个页面为原型<template>
<view :class="['app-container', theme]">
<view class="content">
<view class="overlay">
<!-- 顶部 Header -->
<view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }">
<view class="header-inner">
</template> <script setup> import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import { ref, reactive, onMounted, inject } from 'vue' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { onLoad } from '@dcloudio/uni-app' // 全局主题 const store = useMainStore() const { theme } = storeToRefs(store) // 顶部安全区与标题栏高度 const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) function EnteGroupSettings() { } // 到这结束返回的数据 // 返回上一页 const navigateBack = () => { uni.navigateBack() } onLoad((option) => { if (option) { } else { console.log('没有参数') } }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container { /* 如果有深色 / 浅色主题,在这里根据 theme 做适配 */ } .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; // 整体居中 height: 100%; } /* 左右两块宽度相同,但不占位 */ .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; // 足够容纳图标/文字 display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } /* 中间标题:绝对居中 */ .center-box { flex: none; // 不拉伸 } </style>参考图片实现图片的具体内容<template>text<view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26"></uni-icons> </view> <view class="center-box"> <text class="title-text"></text> </view> <view @click="EnteGroupSettings" class="right-box"> <uni-icons type="more-filled" color="#000000" size="22" /> </view> </view> </view> <!-- 可滚动内容区域 --> <view style="width: 100%;display: flex;justify-content: center;align-items: center;" class=""> <view style="width: 94%;height: 100%; " class=""> </view> </view> </view> </view> </view>
</template> <script setup> import ajax from '@/api/ajax.js' import { ref, reactive, onMounted, inject, computed, nextTick, getCurrentInstance } from 'vue' import { onReachBottom, onPullDownRefresh, onLoad } from '@dcloudio/uni-app' import advertisement from '@/components/advertisement.vue' import MaramleeWaterfallsImage from '@/components/maramlee-waterfalls-flow/maramlee-waterfalls-flow.vue' import MaramleeWaterfallsVideo from '@/components/maramlee-waterfalls-flow/MaramleeWaterfallsVideo.vue' import FeatureSortManager from '@/utils/featureSort.js' const PAGE_SIZE = 20 const lazyLoadingImg = ref('/static/uView/loading.png') const lazyErrorImg = ref('/static/uView/load_error.png') const defaultIcon = '/static/default-icon.png' const STORAGE_KEY = 'recommend_functional_area_v1' const productId = ref('') // ✅ 按你要求:用 ref(inject(...)) 取前缀 const webProjectUrl = ref(inject('webProjectUrl') || '') // ✅ 统一资源前缀(图片和视频同前缀) const FILE_BASE = computed(() => { const base = String(webProjectUrl.value || '').trim() const fallback = 'http://47.99.182.213:7018/' const u = base || fallback return u.endsWith('/') ? u : u + '/' }) function normalizePath(u = '') { let s = String(u || '').trim() if (!s) return '' // 去掉后端有时带的 "xxx" 或 'xxx' s = s.replace(/^['"]+|['"]+$/g, '') // 去掉 \" 这种 s = s.replace(/\\"/g, '') return s } function toAbsUrl(u = '') { const s0 = normalizePath(u) if (!s0) return '' if (/^https?:\/\//i.test(s0)) return s0 return FILE_BASE.value + s0.replace(/^\/+/, '') } /** ✅ 安卓判定:禁用 transition-group 防闪烁/消失 */ const isAndroid = ref(false) try { const sys = uni.getSystemInfoSync() isAndroid.value = String(sys?.platform || '').toLowerCase() === 'android' } catch (e) { isAndroid.value = false } const activeIndex = ref(-1) const showAd = ref(true) const list = ref([]) const imageList = computed(() => list.value.filter((it) => it._mediaType === 'image')) const videoList = computed(() => list.value.filter((it) => it._mediaType === 'video')) const loading = ref(false) const hasMore = ref(true) const rowStartIdx = ref(0) const IMG_EXT = /\.(png|jpe?g|webp|gif|bmp|svg|avif)$/i const VID_EXT = /\.(mp4|mov|m4v|3gp|webm|m3u8)$/i function isVideoFilename(name = '') { return VID_EXT.test(String(name || '')) } function isImageFilename(name = '') { return IMG_EXT.test(String(name || '')) } function pickFirstImageFile(files = []) { if (!Array.isArray(files)) return null // ✅ 只用 filename 判断(按你要求) return files.find((x) => isImageFilename(x?.filename)) || null } function pickFirstVideoFile(files = []) { if (!Array.isArray(files)) return null // ✅ 只用 filename 判断(按你要求) return files.find((x) => isVideoFilename(x?.filename)) || null } function mapGoodsItem(raw) { const files = raw.mallGoodsFileArray || [] const imgFile = pickFirstImageFile(files) const vidFile = pickFirstVideoFile(files) // ✅ cover:优先真实图片;没有则兜底 let cover = '' if (imgFile?.filename) cover = toAbsUrl(imgFile.filename) if (!cover) { cover = 'https://pic.rmb.bdstatic.com/bjh/bb91963a38dc/250824/d5cbe59766935d011cdbf258ebf0fd36.jpeg@c_1,w_1080,h_1920,x_0,y_0?for=bg' } // ✅ videoUrl:严格用 filename 拼(修复视频不显示) let videoUrl = '' if (vidFile?.filename) { videoUrl = toAbsUrl(vidFile.filename) } // ✅ poster:视频优先用图片文件;没有就用 cover let posterUrl = '' if (imgFile?.filename) posterUrl = toAbsUrl(imgFile.filename) if (!posterUrl) posterUrl = cover const mediaType = videoUrl ? 'video' : 'image' return { id: raw.id, title: raw.title || '', price: raw.price || '', originalPrice: raw.originalPrice || '', joinSharePlanCount: raw.joinSharePlanCount ?? raw.JoinSharePlanCount ?? 0, coverUrl: cover, videoUrl, posterUrl, _mediaType: mediaType, _raw: raw } } async function fetchList() { if (loading.value || !hasMore.value) return loading.value = true try { const res = await ajax.post({ url: 'mallGoodsIndexList', data: { productId: productId.value || '', rowStartIdx: rowStartIdx.value, rowCount: PAGE_SIZE }, method: 'POST' }) console.log(res, '里面的数据来源信息1111111111111111') const ok = res?.data?.result === 'success' const rows = res?.data?.mallGoodsGroupArray || [] if (!ok) throw new Error(res?.data?.message || '接口返回失败') const mapped = rows.map(mapGoodsItem) if (mapped.length < PAGE_SIZE) hasMore.value = false rowStartIdx.value += mapped.length list.value = list.value.concat(mapped) } catch (e) { console.error('fetchList error:', e) uni.showToast({ title: '加载失败', icon: 'none' }) } finally { loading.value = false } } async function refreshList() { if (loading.value) return hasMore.value = true rowStartIdx.value = 0 list.value = [] await fetchList() uni.stopPullDownRefresh() } function onImagesLaidOut() {} function onCardClick(item) { if (!item) return uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?id=' + encodeURIComponent(JSON.stringify(item)) }) } function handleDelete({ item, index }) { if (!item) return uni.showModal({ title: '删除', content: '确定删除此商品?', confirmText: '确定', success(res) { if (res.confirm) { const i = typeof index === 'number' ? index : list.value.findIndex((x) => x.id === item.id) if (i >= 0) { list.value.splice(i, 1) uni.showToast({ title: '删除成功', icon: 'none' }) } } } }) } onLoad((opts) => { productId.value = opts?.productId || '' }) onMounted(() => fetchList()) onReachBottom(() => fetchList()) onPullDownRefresh(() => refreshList()) const defaultFunctionalArea = [{ id: 1, name: '咖值通惠卡' }, { id: 2, name: '共享义拍' }, { id: 3, name: '共享义卖' }, { id: 4, name: '共享票根' }, { id: 5, name: '咖识共享' }, { id: 6, name: '消费返利' }, { id: 7, name: '特惠折扣' }, { id: 8, name: '折扣广场' }, { id: 9, name: '积分商城' }, { id: 10, name: '咖值汇' } ] const featureManager = new FeatureSortManager(STORAGE_KEY, defaultFunctionalArea) const featureList = ref(featureManager.getList()) const dragContext = reactive({ dragging: false, activeId: null, startIndex: -1, overIndex: -1, rects: [], shouldPreventClick: false, shouldPersist: false, pendingIndex: -1 }) const instance = getCurrentInstance() async function measureGrid() { return new Promise((resolve) => { uni.createSelectorQuery() .in(instance?.proxy) .selectAll('.grid-item') .boundingClientRect((rects) => { dragContext.rects = Array.isArray(rects) ? rects : [] resolve(dragContext.rects) }) .exec() }) } function findIndexByPoint(x, y) { const rects = dragContext.rects if (!rects || !rects.length) return -1 let matchIndex = rects.findIndex((rect) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) if (matchIndex !== -1) return matchIndex let minDist = Infinity rects.forEach((rect, idx) => { const cx = rect.left + rect.width / 2 const cy = rect.top + rect.height / 2 const dist = Math.hypot(cx - x, cy - y) if (dist < minDist) { minDist = dist matchIndex = idx } }) return matchIndex } function onFeatureTouchStart(index) { dragContext.pendingIndex = index dragContext.shouldPreventClick = false } async function onFeatureLongPress(index) { const item = featureList.value[index] if (!item) return dragContext.dragging = true dragContext.activeId = item.id dragContext.startIndex = index dragContext.overIndex = index dragContext.shouldPersist = false dragContext.shouldPreventClick = true await nextTick() await measureGrid() } async function onFeatureTouchLongPress(index, e) { return onFeatureLongPress(index, e) } function onFeatureTouchMove(e) { if (!dragContext.dragging) return if (dragContext.rects.length === 0) return const touch = e.touches && e.touches[0] if (!touch) return const targetIndex = findIndexByPoint(touch.clientX, touch.clientY) if (targetIndex === -1) return const currentIndex = featureList.value.findIndex((it) => String(it.id) === String(dragContext.activeId)) if (currentIndex === -1 || currentIndex === targetIndex) return dragContext.shouldPersist = true const updated = featureList.value.slice() const [moved] = updated.splice(currentIndex, 1) updated.splice(targetIndex, 0, moved) featureList.value = updated.map((item, idx) => ({ ...item, orderIndex: idx })) dragContext.overIndex = targetIndex nextTick(() => measureGrid()) } function onFeatureTouchEnd() { if (!dragContext.dragging) { dragContext.pendingIndex = -1 return } const shouldPersist = dragContext.shouldPersist dragContext.dragging = false dragContext.activeId = null dragContext.startIndex = -1 dragContext.overIndex = -1 dragContext.rects = [] dragContext.pendingIndex = -1 dragContext.shouldPersist = false if (shouldPersist) { const orderedIds = featureList.value.map((item) => item.id) featureList.value = featureManager.applyManualOrder(orderedIds, { lock: true }) } setTimeout(() => { dragContext.shouldPreventClick = false }, 100) } function handleFeatureTap(item) { if (dragContext.dragging || dragContext.shouldPreventClick) return handleFeatureAction(item) const nextList = featureManager.recordUsage(item.id) featureList.value = nextList const newIndex = nextList.findIndex((it) => String(it.id) === String(item.id)) activeIndex.value = newIndex } function handleFeatureAction(item) { switch (item.name) { case '共享票根': uni.navigateTo({ url: '/pages/ticketStubPage/moviePage' }) break case '咖值通惠卡': case '共享义拍': uni.navigateTo({ url: '/pages/Share/SharePhoto/SharePhoto' }) break case '共享义卖': uni.navigateTo({ url: '/pages/Share/SharePhoto/charitysale' }) break default: break } } const imageMap = { 咖值通惠卡: 'images/appImg/fu_img.png', 会员秒送: 'images/appImg/fu_img.png', 咖值益卖: 'images/appImg/fu_img.png' } const getThumb = (name) => { const path = imageMap[name] if (path) return (webProjectUrl.value || '') + path return defaultIcon } const option = reactive([{ id: 11, name: '共享日榜', img: 'https://pic.rmb.bdstatic.com/bjh/bb8e7ff1b4e/250120/f275f90fc26f77c0f3de1fbe5f7321cc.jpeg' }, { id: 12, name: '共享月榜', img: 'https://wx1.sinaimg.cn/mw690/0079ZTGxly1hrr14pporjj31402eo4qp.jpg' }, { id: 13, name: '共享年榜', img: 'https://qcloud.dpfile.com/pc/F4ckXDalfN7WYer3uDpMdFbupMRu1sO6o4D36KaqcPZNOjhI_EJzeBbPPYSwXW1h.jpg' } ]) </script> <style lang="scss" scoped> .recommend-page { width: 100%; min-height: 100vh; background: #ededed; box-sizing: border-box; } .block { margin-top: 32rpx; background: #ffffff; border-radius: 12rpx; border: 1rpx solid #eaeaea; } .card { padding: 20rpx; display: flex; justify-content: center; } .func-grid { width: 100%; } .grid-box { display: grid; grid-template-columns: repeat(5, 1fr); gap: 24rpx; } .grid-item { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; position: relative; padding: 8rpx 0; transition: transform 0.18s ease, box-shadow 0.18s ease; touch-action: manipulation; } .grid-item.is-dragging { transform: scale(1.06); box-shadow: 0 24rpx 48rpx rgba(0, 0, 0, 0.12); z-index: 2; } .grid-thumb { width: 100rpx; height: 100rpx; border-radius: 12rpx; background: #d89833; display: flex; align-items: center; justify-content: center; } .grid-thumb-inner { width: 48rpx; height: 48rpx; } .thumb-img { width: 100%; height: 100%; border-radius: 8rpx; } .grid-title { margin-top: 8rpx; font-size: 24rpx; color: #333; text-align: center; } :deep(.grid-enter-active), :deep(.grid-leave-active) { transition: all 0.18s ease; } :deep(.grid-enter-from), :deep(.grid-leave-to) { opacity: 0; transform: scale(0.96); } :deep(.grid-move) { transition: transform 0.18s ease; } .triple-row { background: #fff; padding: 20rpx; margin-top: 14rpx; display: flex; justify-content: space-between; } .triple-item { width: 152rpx; text-align: center; } .triple-btn { height: 60rpx; width: 100%; border-radius: 12rpx; overflow: hidden; } .triple-img { width: 100%; height: 100%; border-radius: 12rpx; } .triple-text { margin-top: 8rpx; font-size: 24rpx; color: #333; } .ad-wrap { margin-top: 20rpx; } .waterfalls-wrap { padding: 10rpx 16rpx 40rpx; } .cnt { padding: 16rpx; background: #fff; border-radius: 0 0 12rpx 12rpx; .title { font-size: 28rpx; line-height: 40rpx; font-weight: 500; } /* ✅ 防止 u-line-2 在某些端不生效 */ .u-line-2 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; word-break: break-all; } .sub-row { margin-top: 8rpx; display: flex; align-items: baseline; gap: 12rpx; .price { font-size: 32rpx; font-weight: 600; color: #ff6034; } .origin { font-size: 24rpx; color: #999; text-decoration: line-through; } } .meta { margin-top: 8rpx; font-size: 22rpx; color: #666; } } .empty, .footer-loading { text-align: center; padding: 36rpx 0; color: #888; } </style><template>text<view class="recommend-page"> <!-- 顶部功能宫格(可拖拽排序 + 点击频次排序) --> <view class="card block"> <view class="func-grid"> <!-- ✅ 安卓真机禁用 transition-group:避免点击/重排闪烁导致“消失” --> <template v-if="!isAndroid"> <transition-group name="grid" tag="view" class="grid-box"> <view v-for="(item, index) in featureList" :key="String(item.id)" class="grid-item" :class="{ 'is-dragging': dragContext.dragging && String(dragContext.activeId) === String(item.id) }" @touchstart="onFeatureTouchStart(index)" @longpress="onFeatureLongPress(index, $event)" @touchmove.stop="onFeatureTouchMove" @touchend="onFeatureTouchEnd" @touchcancel="onFeatureTouchEnd" @tap.stop="handleFeatureTap(item)"> <view class="grid-thumb"> <view class="grid-thumb-inner"> <image class="thumb-img" mode="aspectFill" :src="getThumb(item.name)" /> </view> </view> <view class="grid-title">{{ item.name }}</view> </view> </transition-group> </template> <template v-else> <view class="grid-box"> <view v-for="(item, index) in featureList" :key="String(item.id)" class="grid-item" :class="{ 'is-dragging': dragContext.dragging && String(dragContext.activeId) === String(item.id) }" @touchstart="onFeatureTouchStart(index)" @longpress="onFeatureTouchLongPress(index, $event)" @touchmove.stop="onFeatureTouchMove" @touchend="onFeatureTouchEnd" @touchcancel="onFeatureTouchEnd" @tap.stop="handleFeatureTap(item)"> <view class="grid-thumb"> <view class="grid-thumb-inner"> <image class="thumb-img" mode="aspectFill" :src="getThumb(item.name)" /> </view> </view> <view class="grid-title">{{ item.name }}</view> </view> </view> </template> </view> </view> <!-- 三个按钮 --> <view class="triple-row"> <view v-for="(item, index) in option" :key="index" class="triple-item"> <view class="triple-btn"> <image :src="item.img" class="triple-img" mode="aspectFill" /> </view> <view class="triple-text">{{ item.name }}</view> </view> </view> <!-- 广告位 --> <view class="ad-wrap" v-if="showAd"> <advertisement /> </view> <!-- 图片瀑布流 --> <view class="waterfalls-wrap" v-if="imageList.length"> <MaramleeWaterfallsImage :list="imageList" :cols="2" :offset="12" id-key="id" image-src-key="coverUrl" :caption-estimate="120" :lazy-loading-img="lazyLoadingImg" :lazy-error-img="lazyErrorImg" :lazy-threshold="320" :radius="12" :image-style="{ borderRadius: '12rpx' }" :show-index-badge="true" @image-load="onImagesLaidOut" @wapper-lick="onCardClick" @delete="handleDelete"> <template #default="{ item }"> <view class="cnt"> <view class="title u-line-2">{{ item.title }}</view> <view class="sub-row"> <text class="price">¥{{ item.price }}</text> <text v-if="item.originalPrice" class="origin">¥{{ item.originalPrice }}</text> </view> <view class="meta">参与共享:{{ item.joinSharePlanCount || 0 }}</view> </view> </template> </MaramleeWaterfallsImage> </view> <!-- 视频瀑布流 --> <view class="waterfalls-wrap" v-if="videoList.length"> <MaramleeWaterfallsVideo :list="videoList" :cols="2" :offset="12" id-key="id" video-src-key="videoUrl" poster-key="posterUrl" :caption-estimate="120" :lazy-loading-img="lazyLoadingImg" :lazy-error-img="lazyErrorImg" :lazy-threshold="320" :radius="12" :image-style="{ borderRadius: '12rpx' }" :show-index-badge="true" :autoplay="false" :muted="true" :loop="false" @media-load="onImagesLaidOut" @wapper-lick="onCardClick" @delete="handleDelete"> <template #default="{ item }"> <view class="cnt"> <view class="title u-line-2">{{ item.title }}</view> <view class="sub-row"> <text class="price">¥{{ item.price }}</text> <text v-if="item.originalPrice" class="origin">¥{{ item.originalPrice }}</text> </view> <view class="meta">参与共享:{{ item.joinSharePlanCount || 0 }}</view> </view> </template> </MaramleeWaterfallsVideo> </view> <!-- 空态/底部 --> <view v-if="!loading && imageList.length === 0 && videoList.length === 0" class="empty"> <text>暂无数据</text> </view> <view class="footer-loading"> <text v-if="loading">加载中...</text> <text v-else-if="!hasMore">没有更多了</text> </view> </view>
</template> <script setup> import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance, nextTick, computed } from 'vue' const emit = defineEmits(['image-load', 'image-click', 'wapper-lick', 'delete', 'wrapper-click']) const props = defineProps({ list: { type: Array, required: true }, offset: { type: Number, default: 10 }, idKey: { type: String, default: 'id' }, imageSrcKey: { type: String, default: 'image_url' }, cols: { type: Number, default: 2, validator: n => n >= 2 }, imageStyle: { type: Object, default: () => ({}) }, single: { type: Boolean, default: false }, // 估算:文字区域高度(px) captionEstimate: { type: Number, default: 100 }, // 懒加载占位/错误图 lazyLoadingImg: { type: String, default: 'https://qcloud.dpfile.com/pc/uQ1zuT6Oh9geqVbfkNBNt3DgaeG5VEDBZZ-rOxuU8BP1JqNcWwFU-9phykgJcvQC.jpg' }, lazyErrorImg: { type: String, default: 'https://img2.baidu.com/it/u=2719038645,3676702642&fm=253&app=138&f=JPEG?w=500&h=750' }, lazyThreshold: { type: [String, Number], default: 300 }, // 是否用 DOM 兜底测量 measureHidden: { type: Boolean, default: true }, // 兜底占位高度 lazyHeight: { type: [String, Number], default: 450 }, showIndexBadge: { type: Boolean, default: true }, // 圆角 radius: { type: [String, Number], default: 6 } }) const instance = getCurrentInstance() /** ====== 容器 & 布局数据 ====== */ const containerWidth = ref(0) const height = ref(0) const positions = ref([]) const colHeights = ref([]) // ✅ 卡片真实高度缓存(DOM 实测) const heightById = ref(new Map()) // 懒加载相关 const realSrcMap = ref(new Map()) const loadedIds = ref(new Set()) const errorIds = ref(new Set()) const observers = ref(new Map()) /** ====== 计算属性 ====== */ const itemWidth = computed(() => { if (!containerWidth.value) return 0 return (containerWidth.value - props.offset * (props.cols - 1)) / props.cols }) const borderRadius = computed(() => (typeof props.radius === 'number' ? `${props.radius}rpx` : String(props.radius))) const lazyHeightNum = computed(() => Number(props.lazyHeight) || 450) /** ====== 工具函数 ====== */ function getId(item) { return item[props.idKey] } function getSrc(item) { return item[props.imageSrcKey] || '' } function imgIdOf(item) { return 'img-' + getId(item) } function itemDomId(item) { return 'waterfalls-list-id-' + getId(item) } function realSrcOf(item) { return realSrcMap.value.get(getId(item)) || '' } function isLoaded(item) { return loadedIds.value.has(getId(item)) } function isError(item) { return errorIds.value.has(getId(item)) } function hashStr(str) { let h = 0 for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0 return Math.abs(h) } /** * 估算图高:1:1 / 4:3 / 3:4 */ function estimateImgHeight(item) { if (!itemWidth.value) return 0 const id = String(getId(item) ?? '') const ratios = [1, 4 / 3, 3 / 4] const r = ratios[hashStr(id) % ratios.length] return Math.round(itemWidth.value * r) } function getWindowHeight() { try { const info = uni.getSystemInfoSync() return Number(info?.windowHeight) || 800 } catch (e) { return 800 } } /** ====== 瀑布流核心 ====== */ function getShortestColIndex(arr) { let min = Infinity, idx = 0 for (let i = 0; i < arr.length; i++) { if (arr[i] < min) { min = arr[i]; idx = i } } return idx } function resetColumns() { colHeights.value = Array.from({ length: props.cols }, () => 0) positions.value = new Array(props.list.length) height.value = 0 } function cardEstimatedHeight(item) { return (estimateImgHeight(item) || lazyHeightNum.value) + Number(props.captionEstimate || 0) } // ✅ 首屏兜底:把可视区域的 realSrc 先塞进去(避免 observer 某些端不触发) function eagerLoadAboveFold() { const winH = getWindowHeight() const threshold = Number(props.lazyThreshold) || 300 for (let i = 0; i < props.list.length; i++) { const item = props.list[i] const id = getId(item) const top = positions.value[i]?.top ?? 0 if (top <= winH + threshold) { if (!realSrcMap.value.get(id)) { realSrcMap.value.set(id, getSrc(item)) } } } } function reflowAll() { if (!itemWidth.value) return resetColumns() for (let i = 0; i < props.list.length; i++) { const item = props.list[i] const id = getId(item) const h = heightById.value.get(id) || cardEstimatedHeight(item) const col = getShortestColIndex(colHeights.value) const left = col * (itemWidth.value + props.offset) const top = colHeights.value[col] positions.value[i] = { top, left } colHeights.value[col] += h + props.offset } const maxH = Math.max(...colHeights.value, 0) height.value = Math.max(0, maxH - props.offset) eagerLoadAboveFold() emit('image-load') } async function updateLayoutAfterMeasure() { await nextTick() reflowAll() } /** ====== 尺寸测量 ====== */ function measureContainer(cb) { if (containerWidth.value) { cb && cb(); return } const q = uni.createSelectorQuery().in(instance?.proxy) q.select('.waterfalls-box') .boundingClientRect(rect => { containerWidth.value = (rect && rect.width) || 0 cb && cb() }) .exec() } function measureCardByIndex(index) { const item = props.list[index] if (!item) return const domId = '#' + itemDomId(item) const q = uni.createSelectorQuery().in(instance?.proxy) q.select(domId) .boundingClientRect(rect => { const realH = (rect && rect.height) || 0 if (realH > 0) { const id = getId(item) if (Math.abs((heightById.value.get(id) || 0) - realH) > 1) { heightById.value.set(id, realH) updateLayoutAfterMeasure() } } }) .exec() } /** ====== 图片懒加载 ====== */ function ensureObserverFor(item) { const id = getId(item) if (realSrcMap.value.get(id)) return if (observers.value.has(id)) return const sel = '#' + itemDomId(item) const obs = uni.createIntersectionObserver(instance?.proxy, { thresholds: [0, 0.01, 0.1], observeAll: false }).relativeToViewport({ bottom: Number(props.lazyThreshold) || 300 }) obs.observe(sel, res => { if (res && res.intersectionRatio > 0) { realSrcMap.value.set(id, getSrc(item)) obs.disconnect() observers.value.delete(id) } }) observers.value.set(id, obs) } function cleanupObservers() { observers.value.forEach(o => { try { o.disconnect() } catch (e) {} }) observers.value.clear() } function onRealImageLoad(ev, index, item) { const id = getId(item) loadedIds.value.add(id) // ✅ 无论有没有天然宽高,都做一次 DOM 实测卡片高度(解决“底部被截、文案不显示”) nextTick(() => { measureCardByIndex(index) }) } function onRealImageError(index, item) { const id = getId(item) errorIds.value.add(id) nextTick(() => measureCardByIndex(index)) } /** ====== 删除 ====== */ function requestDelete(item, index) { emit('delete', { item, index }) } /** ====== 监听 ====== */ watch( () => [props.list.length, props.cols, props.offset], async () => { await nextTick() measureContainer(() => { props.list.forEach(it => ensureObserverFor(it)) reflowAll() }) }, { immediate: true } ) watch( () => props.list, () => { const validIds = new Set(props.list.map(getId)); [heightById.value, realSrcMap.value].forEach(mp => { Array.from(mp.keys()).forEach(k => { if (!validIds.has(k)) mp.delete(k) }) }); [loadedIds.value, errorIds.value].forEach(st => { Array.from(st.values()).forEach(k => { if (!validIds.has(k)) st.delete(k) }) }) props.list.forEach(it => ensureObserverFor(it)) nextTick(() => eagerLoadAboveFold()) }, { deep: true } ) onMounted(() => { measureContainer(() => { props.list.forEach(it => ensureObserverFor(it)) reflowAll() }) }) onBeforeUnmount(() => { cleanupObservers() }) </script> <style scoped lang="scss"> $radius: 12rpx; .waterfalls-box { position: relative; width: 100%; /* ✅ 关键:不要 hidden,否则布局估算偏差会截断底部 */ overflow: visible; min-height: 400rpx; } .waterfalls-list { width: calc((100% - var(--offset) * (var(--cols) - 1)) / var(--cols)); position: absolute; background-color: #fff; border-radius: $radius; transition: top 180ms ease, left 180ms ease; will-change: top, left; &:active { opacity: 0.96; } } .img-wrap { position: relative; width: 100%; overflow: hidden; border-radius: $radius $radius 0 0; background: #f3f3f3; } .state-layer { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: linear-gradient(90deg, #f2f2f2, #fafafa, #f2f2f2); background-size: 200% 100%; animation: shimmer 1.2s infinite linear; z-index: 2; pointer-events: none; &.error { background: #f7f7f7; animation: none; } } @keyframes shimmer { 0% { background-position: 0% 0; } 100% { background-position: 200% 0; } } .state-text { font-size: 24rpx; color: #999; } .placeholder, .real-img { width: 100%; display: block; border-radius: $radius $radius 0 0; } .real-img.single { border-radius: $radius; } .delete-btn { position: absolute; right: 2%; top: 2%; z-index: 5; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; height: 50rpx; width: 50rpx; border-radius: 50%; } .idx-badge { position: absolute; left: 2%; top: 2%; z-index: 4; background-color: rgba(0, 0, 0, 0.5); color: #fff; height: 40rpx; min-width: 40rpx; padding: 0 10rpx; line-height: 40rpx; border-radius: 999rpx; font-size: 22rpx; text-align: center; } </style><template>text<view class="waterfalls-box" :style="{ minHeight: height + 'px' }"> <view v-for="(item, index) in list" :key="item[idKey]" class="waterfalls-list" :id="itemDomId(item)" :style="{ '--offset': offset + 'px', '--cols': cols, top: (positions[index]?.top || 0) + 'px', left: (positions[index]?.left || 0) + 'px' }" @click="emit('wapper-lick', item)"> <!-- 图片容器:加载前给估算高度;加载后 minHeight=0 让真实图片高度接管(修复“矮图底部空白”) --> <view class="img-wrap" :style="{ minHeight: isLoaded(item) ? '0px' : (estimateImgHeight(item) || lazyHeightNum) + 'px', borderRadius: borderRadius, ...imageStyle }"> <!-- ✅ loading 骨架 --> <view v-if="!isLoaded(item) && !isError(item)" class="state-layer loading"> <image v-if="lazyLoadingImg" class="placeholder" :src="lazyLoadingImg" mode="widthFix" /> <view v-else class="state-text">加载中…</view> </view> <!-- ✅ error 兜底 --> <view v-if="isError(item)" class="state-layer error"> <image v-if="lazyErrorImg" class="placeholder" :src="lazyErrorImg" mode="widthFix" /> <view v-else class="state-text">加载失败</view> </view> <!-- 真实图片:进视口才赋值 realSrc --> <image v-if="!!realSrcOf(item) && !isError(item)" :id="imgIdOf(item)" class="real-img" :class="{ single }" :src="realSrcOf(item)" mode="widthFix" @load="onRealImageLoad($event, index, item)" @error="onRealImageError(index, item)" /> </view> <!-- 插槽内容(卡片下半部文案) --> <slot :item="item" :index="index" /> <!-- 右上角 删除按钮 --> <view class="delete-btn" @click.stop="requestDelete(item, index)"> <view style="color:#fff;">✖</view> </view> <view v-if="showIndexBadge" class="idx-badge">{{ index + 1 }}</view> </view> </view>
</template> <script setup> import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance, nextTick, computed } from 'vue' import ScrollSafeVideo from '@/components/HomepageComponents/Recommend/MyVideo.vue' const emit = defineEmits(['media-load', 'wapper-lick', 'delete', 'wrapper-click']) const props = defineProps({ list: { type: Array, required: true }, offset: { type: Number, default: 10 }, idKey: { type: String, default: 'id' }, videoSrcKey: { type: String, default: 'video_url' }, posterKey: { type: String, default: 'poster_url' }, cols: { type: Number, default: 2, validator: n => n >= 2 }, imageStyle: { type: Object, default: () => ({}) }, single: { type: Boolean, default: false }, captionEstimate: { type: Number, default: 100 }, lazyLoadingImg: { type: String, default: 'https://qcloud.dpfile.com/pc/uQ1zuT6Oh9geqVbfkNBNt3DgaeG5VEDBZZ-rOxuU8BP1JqNcWwFU-9phykgJcvQC.jpg' }, lazyErrorImg: { type: String, default: 'https://img2.baidu.com/it/u=2719038645,3676702642&fm=253&app=138&f=JPEG?w=500&h=750' }, lazyThreshold: { type: [String, Number], default: 300 }, autoplay: { type: Boolean, default: false }, muted: { type: Boolean, default: true }, loop: { type: Boolean, default: false }, measureHidden: { type: Boolean, default: true }, lazyHeight: { type: [String, Number], default: 450 }, showIndexBadge: { type: Boolean, default: true }, radius: { type: [String, Number], default: 12 } }) const instance = getCurrentInstance() /** ====== 容器 & 布局数据 ====== */ const containerWidth = ref(0) const height = ref(0) const positions = ref([]) const colHeights = ref([]) // 卡片真实高度(DOM 实测) const heightById = ref(new Map()) // 视频容器高度(用 meta 比例算出来) const mediaHeightById = ref(new Map()) const realSrcMap = ref(new Map()) const loadedIds = ref(new Set()) const errorIds = ref(new Set()) const observers = ref(new Map()) /** ====== 计算属性 ====== */ const itemWidth = computed(() => { if (!containerWidth.value) return 0 return (containerWidth.value - props.offset * (props.cols - 1)) / props.cols }) const borderRadius = computed(() => typeof props.radius === 'number' ? `${props.radius}rpx` : String(props.radius) ) const lazyHeightNum = computed(() => Number(props.lazyHeight) || 450) /** ====== 工具函数 ====== */ function getId(item) { return item[props.idKey] } function getVideoSrc(item) { return item[props.videoSrcKey] || '' } function posterOf(item) { return item[props.posterKey] || '' } function itemDomId(item) { return 'waterfalls-video-id-' + getId(item) } function realSrcOf(item) { return realSrcMap.value.get(getId(item)) || '' } function isLoaded(item) { return loadedIds.value.has(getId(item)) } function isError(item) { return errorIds.value.has(getId(item)) } function getWindowHeight() { try { const info = uni.getSystemInfoSync() return Number(info?.windowHeight) || 800 } catch (e) { return 800 } } function estimateVideoHeight() { if (!itemWidth.value) return 0 return Math.round(itemWidth.value * (9 / 16)) } function wrapHeightOf(item) { const id = getId(item) const h = mediaHeightById.value.get(id) return h || estimateVideoHeight() || lazyHeightNum.value } function cardEstimatedHeight(item) { return wrapHeightOf(item) + Number(props.captionEstimate || 0) } /** ====== 瀑布流核心 ====== */ function getShortestColIndex(arr) { let min = Infinity, idx = 0 for (let i = 0; i < arr.length; i++) { if (arr[i] < min) { min = arr[i]; idx = i } } return idx } function resetColumns() { colHeights.value = Array.from({ length: props.cols }, () => 0) positions.value = new Array(props.list.length) height.value = 0 } function eagerLoadAboveFold() { const winH = getWindowHeight() const threshold = Number(props.lazyThreshold) || 300 for (let i = 0; i < props.list.length; i++) { const item = props.list[i] const id = getId(item) const top = positions.value[i]?.top ?? 0 if (top <= winH + threshold) { if (!realSrcMap.value.get(id)) { realSrcMap.value.set(id, getVideoSrc(item)) } } } } function reflowAll() { if (!itemWidth.value) return resetColumns() for (let i = 0; i < props.list.length; i++) { const item = props.list[i] const id = getId(item) const h = heightById.value.get(id) || cardEstimatedHeight(item) const col = getShortestColIndex(colHeights.value) const left = col * (itemWidth.value + props.offset) const top = colHeights.value[col] positions.value[i] = { top, left } colHeights.value[col] += h + props.offset } const maxH = Math.max(...colHeights.value, 0) height.value = Math.max(0, maxH - props.offset) eagerLoadAboveFold() emit('media-load') } async function updateLayoutAfterMeasure() { await nextTick() reflowAll() } /** ====== 尺寸测量(DOM 实测卡片高度,避免底部文案被截) ====== */ function measureContainer(cb) { if (containerWidth.value) { cb && cb(); return } const q = uni.createSelectorQuery().in(instance?.proxy) q.select('.waterfalls-box') .boundingClientRect(rect => { containerWidth.value = (rect && rect.width) || 0 cb && cb() }) .exec() } function measureCardByIndex(index) { const item = props.list[index] if (!item) return const domId = '#' + itemDomId(item) const q = uni.createSelectorQuery().in(instance?.proxy) q.select(domId) .boundingClientRect(rect => { const realH = (rect && rect.height) || 0 if (realH > 0) { const id = getId(item) if (Math.abs((heightById.value.get(id) || 0) - realH) > 1) { heightById.value.set(id, realH) updateLayoutAfterMeasure() } } }) .exec() } /** ====== 懒加载 ====== */ function ensureObserverFor(item) { const id = getId(item) if (realSrcMap.value.get(id)) return if (observers.value.has(id)) return const sel = '#' + itemDomId(item) const obs = uni.createIntersectionObserver(instance?.proxy, { thresholds: [0, 0.01, 0.1], observeAll: false }).relativeToViewport({ bottom: Number(props.lazyThreshold) || 300 }) obs.observe(sel, res => { if (res && res.intersectionRatio > 0) { realSrcMap.value.set(id, getVideoSrc(item)) obs.disconnect() observers.value.delete(id) } }) observers.value.set(id, obs) } function cleanupObservers() { observers.value.forEach(o => { try { o.disconnect() } catch (e) {} }) observers.value.clear() } /** ====== 由 iframe 内 video 回传的事件 ====== */ function onPlayerLoaded(index, item, payload) { const id = getId(item) loadedIds.value.add(id) // payload: { w, h } 可能有(有就更新容器高度) const w = Number(payload?.w || 0) const h = Number(payload?.h || 0) if (w > 0 && h > 0 && itemWidth.value) { const vidH = Math.round(itemWidth.value * (h / w)) if (Math.abs((mediaHeightById.value.get(id) || 0) - vidH) > 1) { mediaHeightById.value.set(id, vidH) } } // DOM 实测,确保最后一个卡片文案不被截 nextTick(() => measureCardByIndex(index)) } function onPlayerError(index, item) { const id = getId(item) errorIds.value.add(id) nextTick(() => measureCardByIndex(index)) } function requestDelete(item, index) { emit('delete', { item, index }) } /** ====== 监听 ====== */ watch( () => [props.list.length, props.cols, props.offset], async () => { await nextTick() measureContainer(() => { props.list.forEach(it => ensureObserverFor(it)) reflowAll() }) }, { immediate: true } ) watch( () => props.list, () => { const validIds = new Set(props.list.map(getId)); [heightById.value, realSrcMap.value, mediaHeightById.value].forEach(mp => { Array.from(mp.keys()).forEach(k => { if (!validIds.has(k)) mp.delete(k) }) }); [loadedIds.value, errorIds.value].forEach(st => { Array.from(st.values()).forEach(k => { if (!validIds.has(k)) st.delete(k) }) }) props.list.forEach(it => ensureObserverFor(it)) nextTick(() => eagerLoadAboveFold()) }, { deep: true } ) onMounted(() => { measureContainer(() => { props.list.forEach(it => ensureObserverFor(it)) reflowAll() }) }) onBeforeUnmount(() => { cleanupObservers() }) </script> <style scoped lang="scss"> $radius: 12rpx; .waterfalls-box { position: relative; width: 100%; /* ✅ 不要 hidden,避免估算误差导致底部被裁剪 */ overflow: visible; min-height: 400rpx; } .waterfalls-list { width: calc((100% - var(--offset) * (var(--cols) - 1)) / var(--cols)); position: absolute; background-color: #fff; border-radius: $radius; transition: top 180ms ease, left 180ms ease; will-change: top, left; &:active { opacity: 0.96; } } .img-wrap { position: relative; width: 100%; overflow: hidden; border-radius: $radius $radius 0 0; background: #000; } .state-layer { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: linear-gradient(90deg, #f2f2f2, #fafafa, #f2f2f2); background-size: 200% 100%; animation: shimmer 1.2s infinite linear; z-index: 2; pointer-events: none; &.error { background: #f7f7f7; animation: none; } } @keyframes shimmer { 0% { background-position: 0% 0; } 100% { background-position: 200% 0; } } .state-text { font-size: 24rpx; color: #999; } .poster, .placeholder { width: 100%; height: 100%; display: block; } .real-player { width: 100%; height: 100%; display: block; } .delete-btn { position: absolute; right: 2%; top: 2%; z-index: 5; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; height: 50rpx; width: 50rpx; border-radius: 50%; } .idx-badge { position: absolute; left: 2%; top: 2%; z-index: 4; background-color: rgba(0, 0, 0, 0.5); color: #fff; height: 40rpx; min-width: 40rpx; padding: 0 10rpx; line-height: 40rpx; border-radius: 999rpx; font-size: 22rpx; text-align: center; } </style>下面的瀑布流用这个两个组件就行 然后返回一个完整的页面内容 页面内容根据图片的样式来text<view class="waterfalls-box" :style="{ minHeight: height + 'px' }"> <view v-for="(item, index) in list" :key="item[idKey]" class="waterfalls-list" :id="itemDomId(item)" :style="{ '--offset': offset + 'px', '--cols': cols, top: (positions[index]?.top || 0) + 'px', left: (positions[index]?.left || 0) + 'px' }" @click="emit('wapper-lick', item)"> <!-- 视频容器:固定高度,避免媒体层不撑开 --> <view class="img-wrap" :style="{ height: wrapHeightOf(item) + 'px', borderRadius: borderRadius, ...imageStyle }"> <!-- loading --> <!-- <view v-if="!isLoaded(item) && !isError(item)" class="state-layer loading"> <image v-if="posterOf(item)" class="poster" :src="posterOf(item)" mode="aspectFill" /> <image v-else-if="lazyLoadingImg" class="placeholder" :src="lazyLoadingImg" mode="widthFix" /> <view v-else class="state-text">加载中…</view> </view> --> <!-- error --> <view v-if="isError(item)" class="state-layer error"> <image v-if="lazyErrorImg" class="placeholder" :src="lazyErrorImg" mode="widthFix" /> <view v-else class="state-text">加载失败</view> </view> <!-- ✅ 用 iframe 注入 H5 video:避免原生 video 不跟随滚动/定位 --> <ScrollSafeVideo v-if="!!realSrcOf(item) && !isError(item)" class="real-player" :video-url="realSrcOf(item)" :poster="posterOf(item)" :controls="true" :muted="muted" :loop="loop" :autoplay="autoplay" object-fit="contain" :radius="radius" @loaded="onPlayerLoaded(index, item, $event)" @error="onPlayerError(index, item)" @click="emit('wapper-lick', item)" /> </view> <!-- 文案区 --> <slot :item="item" :index="index" /> <!-- 删除 --> <view class="delete-btn" @click.stop="requestDelete(item, index)"> <view style="color:#fff;">✖</view> </view> <view v-if="showIndexBadge" class="idx-badge">{{ index + 1 }}</view> </view> </view>
vue<!-- pages/index/recommend/DataRecommend.vue(示例文件名,你按自己路由放) --> <template> <view :class="['app-container', theme]"> <view class="page"> <!-- 顶部:参考图的“相机 + 搜索框 + 右侧搜索/AI” --> <view class="header" :style="{ paddingTop: statusBarHeight + 'px' }"> <view class="header-bar" :style="{ height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="h-left" @tap="onCamera"> <uni-icons type="camera-filled" size="22" color="#222" /> </view> <view class="h-search" @tap="onSearch"> <uni-icons type="search" size="18" color="#9aa0a6" /> <text class="search-placeholder">搜索</text> </view> <view class="h-right" @tap="onAI"> <uni-icons type="search" size="20" color="#222" /> <text class="ai-text">AI</text> </view> </view> </view> </view> <!-- 内容滚动区 --> <scroll-view class="scroll" scroll-y :refresher-enabled="true" :refresher-triggered="pulling" @refresherrefresh="onPullRefresh" @scrolltolower="onScrollToLower" > <!-- 顶部4宫格:参考图“数据用品/数据服务/数据保险/数据知识” --> <view class="func-card"> <view class="func-item" v-for="it in funcList" :key="it.id" @tap="onFuncTap(it)"> <view class="func-icon"> <uni-icons :type="it.icon" size="26" color="#4b4b4b" /> </view> <text class="func-text">{{ it.name }}</text> </view> </view> <!-- 瀑布流(图片) --> <view class="waterfalls-wrap" v-if="imageList.length"> <MaramleeWaterfallsImage :list="imageList" :cols="2" :offset="12" id-key="id" image-src-key="coverUrl" :caption-estimate="118" :lazy-loading-img="lazyLoadingImg" :lazy-error-img="lazyErrorImg" :lazy-threshold="320" :radius="12" :image-style="{ borderRadius: '12rpx' }" :show-index-badge="false" @image-load="noop" @wapper-lick="onCardClick" > <template #default="{ item }"> <!-- 参考图:第一张左上角可能是纯图(不带文案),这里支持 showCaption=false --> <view v-if="item.showCaption !== false" class="card-cnt"> <view class="title u-line-2">{{ item.title }}</view> <view class="row"> <view class="left"> <text class="price">¥{{ item.price }}</text> <text class="sold">已售{{ formatSold(item.soldCount) }}+</text> </view> <!-- 参考图:右下角小圆点按钮 --> <view class="mini-btn" @tap.stop="onQuickAction(item)"> <view class="mini-dot" /> </view> </view> </view> </template> </MaramleeWaterfallsImage> </view> <!-- 瀑布流(视频) --> <view class="waterfalls-wrap" v-if="videoList.length"> <MaramleeWaterfallsVideo :list="videoList" :cols="2" :offset="12" id-key="id" video-src-key="videoUrl" poster-key="posterUrl" :caption-estimate="118" :lazy-loading-img="lazyLoadingImg" :lazy-error-img="lazyErrorImg" :lazy-threshold="320" :radius="12" :image-style="{ borderRadius: '12rpx' }" :show-index-badge="false" :autoplay="false" :muted="true" :loop="false" @media-load="noop" @wapper-lick="onCardClick" > <template #default="{ item }"> <view class="card-cnt"> <view class="title u-line-2">{{ item.title }}</view> <view class="row"> <view class="left"> <text class="price">¥{{ item.price }}</text> <text class="sold">已售{{ formatSold(item.soldCount) }}+</text> </view> <view class="mini-btn" @tap.stop="onQuickAction(item)"> <view class="mini-dot" /> </view> </view> </view> </template> </MaramleeWaterfallsVideo> </view> <view v-if="!loading && imageList.length === 0 && videoList.length === 0" class="empty"> <text>暂无数据</text> </view> <view class="footer-loading"> <text v-if="loading">加载中...</text> <text v-else-if="!hasMore">没有更多了</text> </view> <view class="safe-bottom" /> </scroll-view> </view> </view> </template> <script setup> import { ref, computed, inject, onMounted } from 'vue' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { onLoad } from '@dcloudio/uni-app' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import MaramleeWaterfallsImage from '@/components/maramlee-waterfalls-flow/maramlee-waterfalls-flow.vue' import MaramleeWaterfallsVideo from '@/components/maramlee-waterfalls-flow/MaramleeWaterfallsVideo.vue' /** 主题 */ const store = useMainStore() const { theme } = storeToRefs(store) /** 顶部安全区 */ const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) /** 资源前缀(你项目里已有 inject('webProjectUrl') 的用法就沿用) */ const webProjectUrl = ref(inject('webProjectUrl') || '') const FILE_BASE = computed(() => { const base = String(webProjectUrl.value || '').trim() const fallback = 'http://47.99.182.213:7018/' const u = base || fallback return u.endsWith('/') ? u : u + '/' }) function normalizePath(u = '') { let s = String(u || '').trim() if (!s) return '' s = s.replace(/^['"]+|['"]+$/g, '') s = s.replace(/\\"/g, '') return s } function toAbsUrl(u = '') { const s0 = normalizePath(u) if (!s0) return '' if (/^https?:\/\//i.test(s0)) return s0 return FILE_BASE.value + s0.replace(/^\/+/, '') } /** 顶部4功能入口(参考图文案) */ const funcList = ref([ { id: 1, name: '数据用品', icon: 'shop' }, { id: 2, name: '数据服务', icon: 'home' }, { id: 3, name: '数据保险', icon: 'cloud' }, { id: 4, name: '数据知识', icon: 'more-filled' } ]) function onCamera() { uni.showToast({ title: '相机', icon: 'none' }) } function onSearch() { uni.showToast({ title: '搜索', icon: 'none' }) } function onAI() { uni.showToast({ title: 'AI', icon: 'none' }) } function onFuncTap(it) { uni.showToast({ title: it.name, icon: 'none' }) // 你按业务跳转: // uni.navigateTo({ url: '/pages/xxx/xxx?type=' + it.id }) } /** 列表数据 */ const PAGE_SIZE = 20 const lazyLoadingImg = ref('/static/uView/loading.png') const lazyErrorImg = ref('/static/uView/load_error.png') const loading = ref(false) const hasMore = ref(true) const pulling = ref(false) const rowStartIdx = ref(0) const productId = ref('') const list = ref([]) const imageList = computed(() => list.value.filter((it) => it._mediaType === 'image')) const videoList = computed(() => list.value.filter((it) => it._mediaType === 'video')) const IMG_EXT = /\.(png|jpe?g|webp|gif|bmp|svg|avif)$/i const VID_EXT = /\.(mp4|mov|m4v|3gp|webm|m3u8)$/i const isVideoFilename = (name = '') => VID_EXT.test(String(name || '')) const isImageFilename = (name = '') => IMG_EXT.test(String(name || '')) function pickFirstImageFile(files = []) { if (!Array.isArray(files)) return null return files.find((x) => isImageFilename(x?.filename)) || null } function pickFirstVideoFile(files = []) { if (!Array.isArray(files)) return null return files.find((x) => isVideoFilename(x?.filename)) || null } function mapGoodsItem(raw) { const files = raw.mallGoodsFileArray || [] const imgFile = pickFirstImageFile(files) const vidFile = pickFirstVideoFile(files) let coverUrl = imgFile?.filename ? toAbsUrl(imgFile.filename) : '' if (!coverUrl) { coverUrl = 'https://pic.rmb.bdstatic.com/bjh/bb91963a38dc/250824/d5cbe59766935d011cdbf258ebf0fd36.jpeg@c_1,w_1080,h_1920,x_0,y_0?for=bg' } let videoUrl = vidFile?.filename ? toAbsUrl(vidFile.filename) : '' let posterUrl = imgFile?.filename ? toAbsUrl(imgFile.filename) : coverUrl const mediaType = videoUrl ? 'video' : 'image' // 参考图:带“已售xxxx+”,你按后端真实字段替换 const soldCount = raw.soldCount ?? raw.sellCount ?? raw.saleCount ?? raw.joinSharePlanCount ?? raw.JoinSharePlanCount ?? 0 // 参考图:左上第一张有可能纯图(无文案),这里给一个可控开关(后端有字段就直接用) const showCaption = raw.showCaption ?? true return { id: raw.id, title: raw.title || '', price: raw.price || '', soldCount, coverUrl, videoUrl, posterUrl, showCaption, showAction: true, _mediaType: mediaType, _raw: raw } } async function fetchList() { if (loading.value || !hasMore.value) return loading.value = true try { const res = await ajax.post({ url: 'mallGoodsIndexList', data: { productId: productId.value || '', rowStartIdx: rowStartIdx.value, rowCount: PAGE_SIZE }, method: 'POST' }) const ok = res?.data?.result === 'success' const rows = res?.data?.mallGoodsGroupArray || [] if (!ok) throw new Error(res?.data?.message || '接口返回失败') const mapped = rows.map(mapGoodsItem) if (mapped.length < PAGE_SIZE) hasMore.value = false rowStartIdx.value += mapped.length list.value = list.value.concat(mapped) } catch (e) { console.error('fetchList error:', e) uni.showToast({ title: '加载失败', icon: 'none' }) } finally { loading.value = false } } async function refreshList() { hasMore.value = true rowStartIdx.value = 0 list.value = [] await fetchList() } function onCardClick(item) { if (!item) return uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?id=' + encodeURIComponent(JSON.stringify(item)) }) } function onQuickAction(item) { uni.showToast({ title: '操作', icon: 'none' }) } function formatSold(n) { const v = Number(n || 0) if (!Number.isFinite(v)) return String(n || 0) return v } function noop() {} async function onPullRefresh() { pulling.value = true try { await refreshList() } finally { pulling.value = false } } function onScrollToLower() { fetchList() } onLoad((opts) => { productId.value = opts?.productId || '' }) onMounted(() => { fetchList() }) </script> <style scoped lang="scss"> @import '@/assets/styles/global.scss'; .app-container { width: 100%; min-height: 100vh; } .page { height: 100vh; display: flex; flex-direction: column; background: #f2f2f2; /* 参考图:浅灰底 */ } .header { background: #f2f2f2; } .header-bar { width: 94%; margin: 0 auto; display: flex; align-items: center; } .header-inner { width: 100%; display: flex; align-items: center; gap: 16rpx; } .h-left { width: 72rpx; height: 72rpx; border-radius: 14rpx; background: #ffffff; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04); } .h-search { flex: 1; height: 72rpx; border-radius: 14rpx; background: #ffffff; display: flex; align-items: center; padding: 0 18rpx; gap: 12rpx; box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04); } .search-placeholder { color: #9aa0a6; font-size: 28rpx; } .h-right { min-width: 110rpx; height: 72rpx; border-radius: 14rpx; background: #ffffff; display: flex; align-items: center; justify-content: center; gap: 10rpx; padding: 0 18rpx; box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04); } .ai-text { font-size: 26rpx; color: #222; } .scroll { flex: 1; } /* 顶部4宫格卡片 */ .func-card { width: 94%; margin: 18rpx auto 0; background: #ffffff; border-radius: 14rpx; padding: 18rpx 14rpx; display: flex; justify-content: space-between; box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04); } .func-item { width: 25%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10rpx; } .func-icon { width: 74rpx; height: 74rpx; border-radius: 14rpx; background: #f3f3f3; display: flex; align-items: center; justify-content: center; } .func-text { font-size: 24rpx; color: #333; } /* 瀑布流区 */ .waterfalls-wrap { width: 100%; padding: 16rpx 18rpx 34rpx; box-sizing: border-box; } .card-cnt { padding: 16rpx 16rpx 14rpx; background: #fff; border-radius: 0 0 12rpx 12rpx; } .title { font-size: 26rpx; line-height: 38rpx; color: #222; font-weight: 500; } /* ✅ 防止两行截断在部分端不生效 */ .u-line-2 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; word-break: break-all; } .row { margin-top: 10rpx; display: flex; align-items: center; justify-content: space-between; } .left { display: flex; align-items: baseline; gap: 12rpx; } .price { font-size: 30rpx; font-weight: 700; color: #ff7a1a; /* 参考图橙色价格 */ } .sold { font-size: 22rpx; color: #9aa0a6; } .mini-btn { width: 42rpx; height: 42rpx; border-radius: 999rpx; background: #e9e9e9; display: flex; align-items: center; justify-content: center; } .mini-dot { width: 18rpx; height: 18rpx; border-radius: 999rpx; background: #cfcfcf; } .empty, .footer-loading { text-align: center; padding: 28rpx 0; color: #888; } .safe-bottom { height: 40rpx; } </style>
你把这个页面直接替换你现在的页面内容就能得到和截图一样的“顶部搜索条 + 4入口 + 两列瀑布流卡片(右下角灰色小圆点)”布局;瀑布流部分严格使用你给的 MaramleeWaterfallsImage / MaramleeWaterfallsVideo 两个组件。