用这个页面为原型<template> <view :class="['app-container'...

생성일: 2026년 1월 27일

답변에 사용된 모델 GPT-5.2 Thinking by Chat01

질문

image.png

用这个页面为原型<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">

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 { 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="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 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="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' 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)"> <!-- 视频容器:固定高度,避免媒体层不撑开 --> <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>
</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>下面的瀑布流用这个两个组件就行 然后返回一个完整的页面内容 页面内容根据图片的样式来

사고

Breaking down the page layout

The layout has a top header with a search bar and icons, followed by a white card with 4 icons. Then there’s a waterfall with two columns, each card featuring images. The right cards include title, price, sold count, and a grey circle button. The top-left card has a banner image, no caption. We’ll implement with an overall background, padding, and functional areas, and possibly include a top-left banner as a swiper within the waterfall’s first item.

Adjusting layout to match requirements

The user wants the waterfall components only at the bottom, so I'll integrate them into the list. For some items, I can hide the caption, adjusting it via slot based on item type. For banners, I’ll avoid using the caption and set it to blank. The first item can have a swiper as its own banner.

Clarifying component layout and data mapping

To match the screenshot, I'll implement the waterfall item with showCaption=false for the banner, which will still have the same width but no caption. I'll add a small circle button in the caption area for the action on the second row’s right card. I'll set up the fields: title, price, soldCount, showCaption, and showAction, mapping the soldCount accordingly.

Finalizing layout with header and safe area adjustments

I'll ignore implementing the tabBar since it's not needed right now. Instead, I'll focus on adjusting the header height to accommodate the search bar, and I'll incorporate the safe area functions to ensure everything fits properly. I'll base the layout on the first template’s structure and include the necessary theme class too.

Finalizing header, layout, and functionality

I'll implement the header with a search bar (left camera button, center search box, and right search icon). For the waterfalls, I'll style them to match the screenshot and adjust padding and caption estimates. I'll also include fetchList, onReachBottom, onCardClick, and onQuickAction. For features, I'll use simple uni-icons with customization options and ensure the layout uses scss for styling. We'll also integrate necessary ajax requests, making sure everything compiles properly, including injecting webProjectUrl.

Creating final page code

I'll provide the complete page code as requested, saving it as pages/index/recommend/index.vue, making sure components are imported. The user also asked to ensure the use of @tap instead of @click in uni-app. I'll include the proper setup for backgrounds that match their template within the app-container and .content. This should meet their expectations.

Writing the final Vue page

Here's my final version of the Vue page, structured with scroll-view, waterfalls-wrap for both images and videos, a header with icons, and supporting all the requested functionalities like lazy loading, custom actions, and conditional render for when data is available.

I need to import functions for status bar height, ajax calls, and more while setting up lifecycle hooks like onLoad for handling product IDs and onMounted for fetching the data.

Implementing scroll-view with enhanced functionality

I'm implementing a scroll-view with a refresher, incorporating both the built-in refresher and the uni-app pull-down events for flexibility. I'll handle refreshes with onPullRefresh, trigger fetch with onScrollToLower, and use flex layout for proper heights. For dynamic elements like captions and action buttons, I'll apply visibility logic based on conditions like banners or quick actions. Also, I'll ensure image URLs and extensions are managed properly with placeholders for errors.

Thought for 3m 26s

답변

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 两个组件。

이 Q&A 공유하기