diff --git a/components-diy/diy-image-nav.vue b/components-diy/diy-image-nav.vue index 37fcd4d..cda18cb 100644 --- a/components-diy/diy-image-nav.vue +++ b/components-diy/diy-image-nav.vue @@ -2,43 +2,51 @@ - - - - - - - - + 注意: 微信小程序端原生 show-scrollbar 属性被忽略(仅在 App 端生效), + 无法保证"始终显示/滑动时显示", 因此小程序端改用【自定义滚动条】(.img-nav-scrollbar), + H5 端用 CSS 伪元素(见下方 #ifdef H5 样式) —— 两端视觉/逻辑保持一致。 + fix (2026-09-22): 小程序端"始终显示"失效 —— 见 measureScrollbar() 注释(内容宽度测错)。 --> + + + + + + + + + + - - {{ item.title }} + + {{ item.title }} + + + + + + - + + @@ -69,7 +77,9 @@ export default { _suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转 // PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用 // 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏 - slideScrolling: false + slideScrolling: false, + // 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘) + mpScroll: { viewW: 0, contentW: 0, left: 0 } } }, created() { @@ -87,16 +97,47 @@ export default { self.bindSlideScrollbar(); }); // #endif + // 小程序端:测量滚动条所需的视口/内容宽度(图片尺寸由内联样式决定, 渲染后稳定) + // 多次(0/300/1000ms)重测: 首次渲染时节点可能尚未布局完成, 单次测量容易拿不到宽度 + // → 滚动条不显示; 之后的测量只覆盖有效值, 不会把已测得的宽度清零。 + // #ifdef MP + var selfMp = this; + this.$nextTick(function() { + selfMp.measureScrollbar(); + }); + this._mpMeasureTimers = [300, 1000].map(function(delay) { + return setTimeout(function() { + selfMp.measureScrollbar(); + }, delay); + }); + // #endif }, beforeDestroy() { // #ifdef H5 this.unbindSlideMouseEvents(); this.unbindSlideScrollbar(); // #endif + // #ifdef MP + if (this._mpMeasureTimers) { + this._mpMeasureTimers.forEach(function(t) { clearTimeout(t); }); + this._mpMeasureTimers = null; + } + // #endif }, watch: { componentRefresh(newValue) { // 监听组件刷新 + }, + // 小程序端: 列表/数量变化后, 内容宽度会变, 重新测量自定义滚动条 + 'value.list'(newVal, oldVal) { + // #ifdef MP + if (newVal !== oldVal) { + var self = this; + this.$nextTick(function() { + self.measureScrollbar(); + }); + } + // #endif } }, computed: { @@ -240,6 +281,55 @@ export default { return cls; }, + // 原生 show-scrollbar / enhanced 仅在 App 端生效, 小程序端被忽略且会干扰原生行为, + // 故小程序端一律关闭, 改用自定义滚动条; H5 端这两个属性为 no-op, 不影响。 + enhancedScrollbar() { + // #ifndef MP + return this.scrollBarMode != 'never'; + // #endif + // #ifdef MP + return false; + // #endif + }, + showScrollbarAttr() { + // #ifndef MP + return this.scrollBarMode != 'never'; + // #endif + // #ifdef MP + return false; + // #endif + }, + + // 小程序端自定义滚动条的"是否显示" + showCustomScrollbar() { + // #ifdef MP + if (this.scrollBarMode === 'never') return false; + // 只有 singleSlide(横向可滚动)才有滚动条, 与 H5 的 .singleSlide 样式作用域保持一致 + if (this.value.showStyle != 'singleSlide') return false; + // 内容没有溢出(无需横向滚动)则不显示 + if (!(this.mpScroll.contentW > this.mpScroll.viewW)) return false; + // 滑动时显示: 仅在滚动中显示 + if (this.scrollBarMode === 'slide') return this.slideScrolling; + // 始终显示: 一直显示 + return true; + // #endif + }, + + // 小程序端自定义滚动条 thumb 的内联样式(width + 位移) + scrollbarThumbStyle() { + // #ifdef MP + const viewW = this.mpScroll.viewW; + const contentW = this.mpScroll.contentW; + const left = this.mpScroll.left; + if (!viewW || !contentW || contentW <= viewW) return 'display:none;'; + const thumbW = Math.max(20, Math.floor(viewW * viewW / contentW)); + const maxLeft = contentW - viewW; + const ratio = maxLeft > 0 ? Math.min(1, Math.max(0, left / maxLeft)) : 0; + const thumbLeft = ratio * (viewW - thumbW); + return 'width:' + thumbW + 'px;transform:translateX(' + thumbLeft + 'px);'; + // #endif + }, + // ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ========== // 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。 // 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为: @@ -349,6 +439,75 @@ export default { } }, methods: { + // ========== PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22): 图片展示尺寸 ========== + // 目标: 图片按后台"上传/配置的尺寸"渲染, 不再被撑到最大(小程序/H5 表现一致)。 + // 背景: 原写法直接把 item.imgWidth / item.imgHeight(每项字段)当成内联尺寸, + // 后台往往不下发这两个字段(或下发 "") → 生成 'NaNrpx' 这类非法值被忽略, + // 于是回退到 uni-app 默认尺寸 320px x 240px, 视觉上就是"被撑到最大"。 + // 取值优先级(后台数值单位按 rpx 约定, 经 $util.rpx() 转成当前端可用单位): + // ① 每一项下发的 imgWidth / imgHeight —— 沿用历史约定: 数值 ÷ 2 + // ② 组件级 imgWidth / imgHeight —— 兜底, 同样 ÷ 2 + // ③ 组件级"图片尺寸"imageSize —— 与 diy-graphic-nav / diy-merch-list 一致, × 2 + // ④ 都没有 → 120 正方形, 只做最小安全兜底(远小于 320x240 默认值) + // + // 落地结构: 外层 .image-img(view) 与 都按解析出的尺寸渲染, 两端一致; + // 样式统一由下面的方法输出"字符串"(见 imageBoxStyle 的说明, 对象在小程序端会失效)。 + rpxVal(val) { + // 项目约定: 小程序/App 输出 'Nrpx', H5 输出 'Npx'(uni.upx2px) + if (this.$util && typeof this.$util.rpx === 'function') return this.$util.rpx(val); + return val + 'rpx'; + }, + resolveItemImgSize(item) { + const valid = v => { + if (v === undefined || v === null || v === '') return null; + const n = Number(v); + return (!isNaN(n) && n > 0) ? n : null; + }; + // ① 每一项图片自身尺寸 + const iw = valid(item && item.imgWidth); + const ih = valid(item && item.imgHeight); + if (iw !== null && ih !== null) return { width: iw / 2, height: ih / 2 }; + if (iw !== null) return { width: iw / 2, height: iw / 2 }; + if (ih !== null) return { width: ih / 2, height: ih / 2 }; + // ② 组件级 imgWidth / imgHeight + const v = this.value || {}; + const ciw = valid(v.imgWidth); + const cih = valid(v.imgHeight); + if (ciw !== null && cih !== null) return { width: ciw / 2, height: cih / 2 }; + if (ciw !== null) return { width: ciw / 2, height: ciw / 2 }; + if (cih !== null) return { width: cih / 2, height: cih / 2 }; + // ③ 组件级"图片尺寸" + const is = valid(v.imageSize); + if (is !== null) return { width: is * 2, height: is * 2 }; + // ④ 安全兜底 + return { width: 120, height: 120 }; + }, + // 图片外层占位盒样式(决定图片最终尺寸) + // + // ⚠️ 必须返回"样式字符串", 不能返回对象! + // 返回对象时 mp-weixin 的编译产物是 `style="{{(item.m0)}}"`(m0 是对象), + // 小程序 WXML 不能把对象转成 style 字符串 → 整条样式直接失效: + // · 只有 时 → 图片回退到默认 320x240(看起来"被撑到最大") + // · 外层盒没尺寸 + 图片 width:100% 时 → 高度塌成 0(图片完全不显示) + // diy-graphic-nav.vue 能在小程序正常显示, 正是因为它的 style 编译出来是字符串。 + imageBoxStyle(item) { + const size = this.resolveItemImgSize(item); + const imageSize = Number(this.value && this.value.imageSize); + const font = (!isNaN(imageSize) && imageSize > 0) ? imageSize * 2 : size.height; + return 'font-size:' + this.rpxVal(font) + ';' + + 'width:' + this.rpxVal(size.width) + ';' + + 'height:' + this.rpxVal(size.height) + ';'; + }, + // 图片自身样式(与占位盒同尺寸, 配合 mode="aspectFit" 等比完整显示) + // max-width / max-height 与 diy-graphic-nav 的写法保持一致, 用于兜底夹住尺寸, + // 保证图片不会超出后台配置的大小。 + imageInnerStyle(item) { + const size = this.resolveItemImgSize(item); + const w = this.rpxVal(size.width); + const h = this.rpxVal(size.height); + return 'width:' + w + ';height:' + h + ';max-width:' + w + ';max-height:' + h + ';'; + }, + // ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ========== // 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll, // 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。 @@ -356,6 +515,8 @@ export default { bindSlideScrollbar() { // #ifdef H5 if (this._slideScrollBound) return; + // 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定; + // ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。 var el = this.getSlideContainer(); if (!el) return; this._slideScrollEl = el; @@ -385,6 +546,113 @@ export default { // #endif }, + // 统一的 scroll 事件处理(模板 @scroll 绑定, 小程序/H5 共用) + // · 两端:"滑动时显示"模式 → 维护 slideScrolling(滚动中标记, H5 用于 is-scrolling 类) + // · 小程序端: 驱动自定义滚动条 thumb 位置(原生 show-scrollbar 被忽略, 改用自绘) + onSlideScroll(e) { + if (this.scrollBarMode === 'slide') { + if (!this.slideScrolling) this.slideScrolling = true; + clearTimeout(this._slideScrollTimer); + this._slideScrollTimer = setTimeout(() => { + this.slideScrolling = false; + }, 800); + } + // #ifdef MP + // 首次滚动时若还没拿到尺寸, 先测量一次(图片尺寸由内联样式决定, 渲染后即稳定) + if (!this.mpScroll.viewW || !this.mpScroll.contentW) this.measureScrollbar(); + // scroll-view 的 scroll 事件 detail 自带真实 scrollWidth(小程序原生返回), + // 只要它比当前记录的更大(内容更长), 就更新 —— 保证滚动条长度比例准确 + if (e && e.detail && typeof e.detail.scrollWidth === 'number' + && e.detail.scrollWidth > this.mpScroll.contentW) { + this.mpScroll.contentW = e.detail.scrollWidth; + } + let left = 0; + if (e && e.detail && typeof e.detail.scrollLeft === 'number') left = e.detail.scrollLeft; + this.mpScroll.left = left; + // #endif + }, + + // 小程序端: 测量 scroll-view 视口宽度与内容宽度(供自定义滚动条计算 thumb) + // + // ⚠️ fix (2026-09-22) 「始终显示水平滚动条」在小程序端不生效的根因: + // 原实现只取 .uni-scroll-view-content 的 width 当"内容宽度"。但在 mp-weixin 中, + // 该 view 是块级/flex 容器, 宽度恒等于 scroll-view 宽度(子项只是溢出它而不是撑开它), + // 于是 contentW 永远 == viewW → showCustomScrollbar / scrollbarThumbStyle 判定 + // "内容没有溢出" → 滚动条永不渲染(后台选"始终显示"也无效)。 + // 现改为三级取样, 取到即可用, 且已被测得的有效值不会被后续空测量覆盖: + // ① scroll-view 的 scrollOffset.scrollWidth —— WeChat 原生返回的真实滚动区宽度(首选, 含内边距) + // ② 全部 .image-nav-item 的 rect 跨度 max(right) - min(left) + 左右内边距 —— 最可靠的 DOM 兜底 + // ③ 按后台下发的图片/文本尺寸累加推算(rpx → px) —— 极端情况(查询全部失败)下兜底 + measureScrollbar() { + // #ifdef MP + if (!uni || !uni.createSelectorQuery) return; + var self = this; + var query = uni.createSelectorQuery().in(this); + query.select('.image-nav').fields({ size: true, scrollOffset: true }); + query.selectAll('.image-nav-item').boundingClientRect(); + query.exec(function(res) { + var view = res && res[0]; + if (!view) return; + var viewW = view.width || 0; + var items = res && res[1]; + + // ① 原生 scrollWidth + var contentW = view.scrollWidth || 0; + + // ② 首尾 item 的 rect 跨度(与当前滚动位置无关: 两端同步平移, 差值稳定) + if (contentW <= viewW && items && items.length) { + var minLeft = Infinity; + var maxRight = -Infinity; + for (var i = 0; i < items.length; i++) { + var r = items[i]; + if (!r) continue; + if (r.left < minLeft) minLeft = r.left; + if (r.right > maxRight) maxRight = r.right; + } + if (maxRight > minLeft) { + var padPx = (uni.upx2px ? uni.upx2px(16) : 8) * 2; // .image-nav 左右各 16rpx + var spanW = (maxRight - minLeft) + padPx; + if (spanW > contentW) contentW = spanW; + } + } + + // ③ 数据推算兜底 + if (!contentW) contentW = self.estimateContentWidthPx(); + + self.mpScroll.viewW = viewW; + // 有效值只增不减: 避免后一次空测量把已测得的宽度清零(会导致滚动条突然消失) + if (contentW > 0) self.mpScroll.contentW = contentW; + else if (!self.mpScroll.contentW) self.mpScroll.contentW = 0; + self.mpScroll.left = view.scrollLeft || 0; + }); + // #endif + }, + + // 小程序端: 按后台下发的尺寸推算内容总宽度(rpx → px), 仅在 DOM 测量失败时兜底 + estimateContentWidthPx() { + // #ifdef MP + const list = (this.value && this.value.list) || []; + if (!list.length) return 0; + const cfg = this.imageSpacingConfig; + const gap = cfg.horizontal === null ? 28 : cfg.horizontal; + let totalRpx = 32; // 容器左右 padding(16rpx × 2) + for (let i = 0; i < list.length; i++) { + if (this.value.mode == 'text') { + const w = Number(list[i] && list[i].imgWidth); + totalRpx += (!isNaN(w) && w > 0) ? w / 2 : 0; + } else { + totalRpx += this.resolveItemImgSize(list[i]).width; + } + totalRpx += gap; + } + let winW = 375; + try { + if (uni && uni.getSystemInfoSync) winW = uni.getSystemInfoSync().windowWidth || 375; + } catch (e) {} + return totalRpx * winW / 750; + // #endif + }, + // 页面跳转 redirectTo(link) { if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) { @@ -412,14 +680,10 @@ export default { // #ifdef H5 // 防止重复绑定 if (this._slideEventsBound) return; - var outerEl = this.getSlideOuterEl(); - var innerEl = this.getSlideContainer(); - if (!outerEl || !innerEl) return; - // 关键发现:uni-app 的 web component 内部用了 BetterScroll, - // 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。 - // 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。 - this._slideOuterEl = outerEl; - this._slideEl = innerEl; + // 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。 + // 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生), + // 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。 + // 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。 this._slideEventsBound = true; // document 级别 capture 阶段,先于 uni-app 内部的滚动库触发 document.addEventListener('mousedown', this.onSlideMouseDown, true); @@ -442,50 +706,79 @@ export default { this._slideEventsBound = false; // #endif }, - getSlideContainer() { + // 本组件实例的根元素() + // + // ⚠️ 必须用 this.$el 限定在"本实例"内查找元素! + // 历史实现用 document.querySelector('[data-component-name="diy-image-nav"]'), + // 而同一个页面可以放多个滑动图组件(如 首页的 合作伙伴 + 证书资质 + 服务资质), + // document.querySelector 永远只返回页面上的**第一个**实例 —— + // 于是拖拽/滚轮第 2、3 个组件时,"指针是否在容器内"用的是第 1 个组件的位置, + // 判断不通过直接 return → 表现就是"鼠标拖不动、滚轮也没反应"。 + getSlideRootEl() { // #ifdef H5 - // uni-app 的 scroll-view 在 H5 端编译为 web component, - // 嵌套结构: - // ← 自定义元素 - //
← 第1层,visible - //
← 第2层,overflowX=auto (真滚动容器) - // 真正能响应 scrollLeft 的是第 2 层 div。 - // 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。 - var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]'); - if (!componentRoot) return null; - // 找 overflowX:auto 的 div.uni-scroll-view - var candidates = componentRoot.querySelectorAll('.uni-scroll-view'); - for (var i = 0; i < candidates.length; i++) { - var s = getComputedStyle(candidates[i]).overflowX; - if (s === 'auto' || s === 'scroll') { - return candidates[i]; - } - } - // 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器) - return candidates.length > 0 ? candidates[candidates.length - 1] : null; + var el = this.$el; + if (el && el.nodeType === 1) return el; + return null; // #endif }, + // 用于判断 mousedown/wheel 是否落在容器内(bounding box) getSlideOuterEl() { // #ifdef H5 - // 用于判断 mousedown/wheel 是否落在容器内(用最外层 的 bounding box) - var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]'); - if (!componentRoot) return null; - var sc = componentRoot.querySelector('uni-scroll-view'); - return sc || null; + return this.getSlideRootEl(); + // #endif + }, + getSlideContainer() { + // #ifdef H5 + // uni-app 的 scroll-view 在 H5 端编译为 自定义元素, + // 真正的滚动 div 是内部的 .uni-scroll-view。这里在**本实例范围内**查找: + // ① 优先"内容宽度 > 可视宽度"判定真滚动容器(与 overflow 写法无关, 最可靠) + // ② 其次 overflow-x: auto/scroll + // ③ 再退化为最后一个 .uni-scroll-view + // ④ 最后退化到 .uni-scroll-view-content 的父元素 + var root = this.getSlideRootEl(); + if (!root) return null; + var candidates = root.querySelectorAll('.uni-scroll-view'); + var i; + for (i = 0; i < candidates.length; i++) { + if (candidates[i].scrollWidth > candidates[i].clientWidth + 1) return candidates[i]; + } + for (i = 0; i < candidates.length; i++) { + var s = getComputedStyle(candidates[i]).overflowX; + if (s === 'auto' || s === 'scroll') return candidates[i]; + } + if (candidates.length > 0) return candidates[candidates.length - 1]; + var content = root.querySelector('.uni-scroll-view-content'); + if (content && content.parentElement) return content.parentElement; + return root.querySelector('uni-scroll-view'); + // #endif + }, + // 按需解析本实例的"根元素 + 真滚动容器"(挂载时机不确定, 统一走这里) + ensureSlideEls() { + // #ifdef H5 + var root = this.getSlideRootEl(); + if (!root) return false; + this._slideOuterEl = root; + var el = this.getSlideContainer(); + if (!el) return false; + this._slideEl = el; + // 滚动条显隐依赖真滚动容器的 scroll 事件:mounted 时若还没拿到容器, + // 这里补绑一次(否则"滑动时显示滚动条"也会失效)。 + if (!this._slideScrollBound) this.bindSlideScrollbar(); + return true; // #endif }, onSlideMouseDown(e) { // #ifdef H5 if (this.value.showStyle != 'singleSlide') return; - // document 级别监听,只处理落在容器内的事件 - // 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致), + // document 级别监听,只处理落在"本实例"容器内的事件 + // 关键:e.target 可能是 HTML 本身(uni 内部事件重定向导致), // 所以用 clientX/clientY 判断是否落在容器 bounding box 内。 - if (!this._slideOuterEl) return; + if (!this.ensureSlideEls()) return; var rect = this._slideOuterEl.getBoundingClientRect(); if (!rect) return; var x = e.clientX, y = e.clientY; if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return; - var el = this.getSlideContainer(); + var el = this._slideEl; if (!el) return; this.slideIsDragging = true; this.slideStartX = x; @@ -499,7 +792,7 @@ export default { onSlideMouseMove(e) { // #ifdef H5 if (!this.slideIsDragging) return; - var el = this.getSlideContainer(); + var el = this._slideEl; if (!el) return; var x = (e.clientX != null) ? e.clientX : 0; var deltaX = x - this.slideStartX; @@ -526,7 +819,7 @@ export default { // #ifdef H5 if (!this._suppressNextClick) return; if (!e.target) return; - var outer = this._slideOuterEl; + var outer = this._slideOuterEl || this.getSlideOuterEl(); if (!outer || !outer.contains(e.target)) return; this._suppressNextClick = false; if (e.preventDefault) e.preventDefault(); @@ -537,13 +830,13 @@ export default { onSlideWheel(e) { // #ifdef H5 if (this.value.showStyle != 'singleSlide') return; - if (!this._slideOuterEl) return; - // 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内 + // 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在"本实例"容器内 + if (!this.ensureSlideEls()) return; var rect = this._slideOuterEl.getBoundingClientRect(); if (!rect) return; var x = e.clientX, y = e.clientY; if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return; - var el = this.getSlideContainer(); + var el = this._slideEl; if (!el) return; // 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动 var dx = e.deltaX || 0; @@ -560,6 +853,36 @@ export default {