1107 lines
44 KiB
Vue
1107 lines
44 KiB
Vue
<template>
|
||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
|
||
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
|
||
注意: 微信小程序端原生 show-scrollbar 属性被忽略(仅在 App 端生效),
|
||
无法保证"始终显示/滑动时显示", 因此小程序端改用【自定义滚动条】(.img-nav-scrollbar),
|
||
H5 端用 CSS 伪元素(见下方 #ifdef H5 样式) —— 两端视觉/逻辑保持一致。
|
||
fix (2026-09-22): 小程序端"始终显示"失效 —— 见 measureScrollbar() 注释(内容宽度测错)。 -->
|
||
<view class="image-nav-wrap">
|
||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
|
||
:style="imageSpacingStyle"
|
||
:scroll-x="value.showStyle == 'singleSlide'"
|
||
:show-scrollbar="showScrollbarAttr"
|
||
:enhanced="enhancedScrollbar"
|
||
@scroll="onSlideScroll">
|
||
<view class="uni-scroll-view-content">
|
||
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||
:style="imageItemStyle">
|
||
<!-- 图片部分 -->
|
||
<!-- PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
||
图片按后台上传/配置的尺寸渲染,避免 uni <image> 取不到尺寸时
|
||
回退到浏览器默认 320x240 而被"撑到最大";mode=aspectFit 保证不拉伸变形 -->
|
||
<view v-if="value.mode != 'text'" class="image-img" :style="imageBoxStyle(item)">
|
||
<image v-if="item.link.wap_url" class="image-content" :style="imageInnerStyle(item)"
|
||
mode="aspectFit"
|
||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
||
<image v-else class="image-content" :style="imageInnerStyle(item)" mode="aspectFit"
|
||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
|
||
</view>
|
||
|
||
<!-- 文字部分 -->
|
||
<text class="image-text" :style="{
|
||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||
'font-size': (value.font.size * 2 + 'rpx') + ';',
|
||
'font-weight': value.font.weight + ';',
|
||
'color': value.font.color + ';'
|
||
}">{{ item.title }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
<!-- #ifdef MP -->
|
||
<!-- 小程序端自定义水平滚动条: 原生 show-scrollbar 被忽略, 这里自绘以对齐后台/ H5 -->
|
||
<view v-if="showCustomScrollbar" class="img-nav-scrollbar">
|
||
<view class="img-nav-scrollbar-thumb" :style="scrollbarThumbStyle"></view>
|
||
</view>
|
||
<!-- #endif -->
|
||
</view>
|
||
<ns-login ref="login"></ns-login>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
|
||
import DiyMinx from './minx.js'
|
||
|
||
export default {
|
||
name: 'diy-image-nav',
|
||
props: {
|
||
value: {
|
||
type: Object,
|
||
default: () => ({})
|
||
}
|
||
},
|
||
mixins: [DiyMinx],
|
||
data() {
|
||
return {
|
||
pageWidth: '',
|
||
indicatorDots: false,
|
||
swiperCurrent: 0,
|
||
// H5 鼠标拖拽状态(singleSlide 模式专用)
|
||
slideIsDragging: false,
|
||
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
||
slideStartX: 0,
|
||
slideStartScrollLeft: 0,
|
||
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
|
||
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
|
||
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
|
||
slideScrolling: false,
|
||
// 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘)
|
||
mpScroll: { viewW: 0, contentW: 0, left: 0 }
|
||
}
|
||
},
|
||
created() {
|
||
// 组件创建时的逻辑
|
||
},
|
||
mounted() {
|
||
// H5 端:为 singleSlide 模式的 scroll-view 手动绑定 mouse/wheel 事件
|
||
// 原因:模板里写 @mousedown/@wheel 在小程序/鸿蒙端会产生"未知事件"警告,
|
||
// 所以 H5 专属事件全部在 mounted 中通过 addEventListener 绑定。
|
||
// $nextTick 是为了确保 ref 在 DOM 中已就绪(滚动容器总是存在的,无 v-if)。
|
||
// #ifdef H5
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
self.bindSlideMouseEvents();
|
||
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: {
|
||
// PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 组件背景色
|
||
// 后台"组件背景"面板下发的字段是 componentBgColor(与图片轮播/搜索等组件一致);
|
||
// 兼容别名/数组形态/嵌套容器(避免后台换名或存成数组时前端读不到而完全不上色),
|
||
// 最后兜底组件级 pageBgColor(部分组件后台把组件背景存在这里)。
|
||
// 空值/非法值(图片URL等)返回空串, 此时不输出 background-color, 保持历史透明视觉。
|
||
componentBgColor() {
|
||
const v = this.value || {};
|
||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||
const asColor = val => {
|
||
if (Array.isArray(val)) val = val[0];
|
||
if (typeof val !== 'string') return '';
|
||
const s = val.trim();
|
||
if (!s) return '';
|
||
// 排除图片/URL 等非颜色值
|
||
if (/^(url\(|https?:|data:|upload\/|addon\/|\/)/i.test(s)) return '';
|
||
return s;
|
||
};
|
||
const sources = [v];
|
||
['style', 'config', 'setting', 'data'].forEach(k => {
|
||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||
});
|
||
const candidates = [
|
||
'componentbgcolor', 'componentbackgroundcolor', 'componentcolor', 'componentbg',
|
||
'bgcolor', 'backgroundcolor', 'background',
|
||
'pagebgcolor'
|
||
];
|
||
for (let c = 0; c < candidates.length; c++) {
|
||
for (let s = 0; s < sources.length; s++) {
|
||
for (const key in sources[s]) {
|
||
if (norm(key) === candidates[c]) {
|
||
const color = asColor(sources[s][key]);
|
||
if (color) return color;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return '';
|
||
},
|
||
componentStyle() {
|
||
let style = '';
|
||
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
|
||
|
||
if (this.value.componentAngle == 'round') {
|
||
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||
style += 'border-top-right-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||
style += 'border-bottom-left-radius:' + (2 * this.value.bottomAroundRadius) + 'rpx;';
|
||
style += 'border-bottom-right-radius:' + (2 * this.value.bottomAroundRadius) + 'rpx;';
|
||
}
|
||
|
||
style += 'box-shadow:' + (this.value.ornament.type == 'shadow' ? '0 0 10rpx ' + this.value.ornament.color : '') + ';';
|
||
style += 'border:' + (this.value.ornament.type == 'stroke' ? '2rpx solid ' + this.value.ornament.color : '') + ';';
|
||
|
||
return style;
|
||
},
|
||
|
||
swiperHeight() {
|
||
let height = 0;
|
||
|
||
if (this.value.mode == 'graphic') {
|
||
height = (49 + this.value.imageSize) * this.value.pageCount;
|
||
} else if (this.value.mode == 'img') {
|
||
height = (22 + this.value.imageSize) * this.value.pageCount;
|
||
} else if (this.value.mode == 'text') {
|
||
height = 43 * this.value.pageCount;
|
||
}
|
||
|
||
return 'height:' + (2 * height) + 'rpx';
|
||
},
|
||
|
||
isIndicatorDots() {
|
||
return this.value.carousel.type != 'hide' &&
|
||
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
||
},
|
||
|
||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||
// 后台"水平滚动条"配置: 滑动时显示 / 始终显示 / 永不显示 → 归一化为 slide / always / never
|
||
// 字段名兼容: scrollBar / scrollbar / scrollBarType / horizontalScrollBar / horizontalScrollbar
|
||
// (忽略大小写/下划线/中划线差异, 并向下探测 value.scrollBar / value.style 等嵌套容器)
|
||
// 值兼容: 英文枚举(slide/auto/always/never/none...) / 中文枚举(滑动时显示/始终显示/永不显示)
|
||
// / 数字(0=滑动时显示 1=始终显示 2=永不显示) / 布尔(true=始终显示 false=永不显示)
|
||
// 兜底: 未下发或无法识别 → 'slide' (滑动时显示, 与历史行为一致)
|
||
scrollBarMode() {
|
||
const v = this.value || {};
|
||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||
// 从配置对象中取"滚动条"字段的原始值
|
||
const pickRaw = obj => {
|
||
if (!obj || typeof obj != 'object') return undefined;
|
||
for (const key in obj) {
|
||
const k = norm(key);
|
||
if (k.indexOf('scrollbar') != -1 || k.indexOf('scrolltype') != -1) {
|
||
const val = obj[key];
|
||
if (val && typeof val === 'object') {
|
||
// 子配置对象形态: { scrollBar: { type: 'always' } }
|
||
return val.type !== undefined ? val.type : (val.value !== undefined ? val.value : val.mode);
|
||
}
|
||
if (val !== undefined && val !== null && val !== '') return val;
|
||
}
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
let raw = pickRaw(v);
|
||
// 嵌套容器兜底
|
||
if (raw === undefined) {
|
||
const containers = ['scrollBar', 'scrollbar', 'style', 'config', 'setting', 'data'];
|
||
for (let i = 0; i < containers.length; i++) {
|
||
if (v[containers[i]] && typeof v[containers[i]] === 'object') {
|
||
raw = pickRaw(v[containers[i]]);
|
||
if (raw !== undefined) break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (raw === undefined || raw === null || raw === '') return 'slide';
|
||
if (typeof raw === 'boolean') return raw ? 'always' : 'never';
|
||
if (raw === 0 || raw === '0') return 'slide';
|
||
if (raw === 1 || raw === '1') return 'always';
|
||
if (raw === 2 || raw === '2') return 'never';
|
||
|
||
const map = {
|
||
slide: 'slide', scroll: 'slide', auto: 'slide', scrolling: 'slide', whilescrolling: 'slide', moveslide: 'slide',
|
||
always: 'always', alwaysdisplay: 'always', show: 'always',
|
||
never: 'never', none: 'never', hidden: 'never', hide: 'never',
|
||
// 后台直出中文枚举时兜底
|
||
'滑动时显示': 'slide', '滑动时': 'slide', '滚动时显示': 'slide',
|
||
'始终显示': 'always', '一直显示': 'always', '永久显示': 'always',
|
||
'永不显示': 'never', '从不显示': 'never', '不显示': 'never'
|
||
};
|
||
const hit = map[String(raw).toLowerCase().trim()] || map[norm(raw)];
|
||
return hit || 'slide';
|
||
},
|
||
|
||
// scroll-view 上的滚动条显隐 class: scrollbar-slide / scrollbar-always / scrollbar-never
|
||
// "滑动时显示"在滚动中追加 is-scrolling (由 scroll 监听维护)
|
||
scrollBarClass() {
|
||
const cls = ['scrollbar-' + this.scrollBarMode];
|
||
if (this.scrollBarMode == 'slide' && this.slideScrolling) cls.push('is-scrolling');
|
||
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) 核对, 实际为:
|
||
// imageSpacing 图片横向间距: 相邻图片项之间的横向间隔
|
||
// imageSpacingTop 图片上间距 : 图片区域内侧的上内边距
|
||
// imageSpacingBottom 图片下间距 : 图片区域内侧的下内边距
|
||
// 单位: 按项目惯例(与 diy-group 的 margin.top、图片轮播的 margin.both 一致)换算 rpx = 后台数值 × 2
|
||
// 容错: 除后台现用命名外, 仍按归一化(忽略大小写/下划线/中划线)兼容其它命名,
|
||
// 并向下探测 value.imageSpacing / value.margin / value.padding 等嵌套容器。
|
||
// 兜底: 字段缺失/非法时返回 null, 由 imageSpacingStyle / imageItemStyle 回退历史视觉
|
||
// (横向 28rpx, 上/下沿用 CSS 的 16rpx), 老装修数据不受影响。
|
||
imageSpacingConfig() {
|
||
const v = this.value || {};
|
||
const RATIO = 2; // 后台数值 → rpx 换算比例
|
||
|
||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||
// 非数字(如 'diy'/枚举)或空值 → null, 交给兜底
|
||
const toRpx = val => {
|
||
if (val === undefined || val === null || val === '') return null;
|
||
const n = Number(val);
|
||
if (isNaN(n)) return null;
|
||
return Math.max(0, n) * RATIO;
|
||
};
|
||
|
||
// 查找源: value 自身 + 常见嵌套容器(顶层优先)
|
||
// 注意: 后端偶发把 margin 存成数组(线上该组件 margin: []), 数组不能当容器取裸键, 直接跳过
|
||
const sources = [v];
|
||
['imageSpacing', 'imageSpace', 'spacing', 'space', 'margin', 'padding', 'style', 'config', 'setting']
|
||
.forEach(k => {
|
||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||
});
|
||
|
||
// 三级查找: ① 全源精确名 → ② 全源模糊包含名 → ③ 嵌套容器内的裸键
|
||
// (如 imageSpacing: { horizontal: 3, top: 0, bottom: 0 })
|
||
const pick = (exact, fuzzy, nested) => {
|
||
for (let s = 0; s < sources.length; s++) {
|
||
const src = sources[s];
|
||
for (const key in src) {
|
||
if (exact.indexOf(norm(key)) != -1) {
|
||
const rpx = toRpx(src[key]);
|
||
if (rpx !== null) return rpx;
|
||
}
|
||
}
|
||
}
|
||
for (let s = 0; s < sources.length; s++) {
|
||
const src = sources[s];
|
||
for (const key in src) {
|
||
const k = norm(key);
|
||
if (fuzzy.some(f => k.indexOf(f) != -1)) {
|
||
const rpx = toRpx(src[key]);
|
||
if (rpx !== null) return rpx;
|
||
}
|
||
}
|
||
}
|
||
// 裸键(top/bottom/both...)只在嵌套容器内解释, 避免误取顶层同名字段
|
||
for (let s = 1; s < sources.length; s++) {
|
||
const src = sources[s];
|
||
for (const key in src) {
|
||
if (nested.indexOf(norm(key)) != -1) {
|
||
const rpx = toRpx(src[key]);
|
||
if (rpx !== null) return rpx;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return {
|
||
// 图片横向间距 —— 后台现用命名 imageSpacing(首项命中即可, 后面都是兼容别名)
|
||
horizontal: pick(
|
||
['imagespacing', 'imagespacinghorizontal', 'imagehorizontalspacing', 'imagespacingboth', 'imagespacenum',
|
||
'imagemargin', 'imagemarginnum', 'imagespace', 'horizontalspacing', 'horizontalspacenum', 'imagegap',
|
||
'spacenum', 'marginnum', 'imageverticalspace', 'space', 'gap'],
|
||
['horizontalspacing', 'imagespacing', 'imagespace', 'imagemargin', 'spacenum', 'marginnum', 'imagegap', 'gap'],
|
||
['horizontal', 'horizontalnum', 'lr', 'leftright', 'both', 'bothnum', 'imagespacing', 'num', 'margin', 'space', 'gap']
|
||
),
|
||
// 图片上间距 —— 后台现用命名 imageSpacingTop
|
||
paddingTop: pick(
|
||
['imagespacingtop', 'imagetopspacing', 'imagespacetop', 'imagetopspace', 'imagepaddingtop',
|
||
'paddingtop', 'spacingtop', 'topspace', 'margintop'],
|
||
['imagespacingtop', 'paddingtop', 'spacingtop', 'topspacing', 'topspace', 'margintop'],
|
||
['top', 'topnum', 'paddingtop', 'margintop', 'spacetop', 'spacingtop']
|
||
),
|
||
// 图片下间距 —— 后台现用命名 imageSpacingBottom
|
||
paddingBottom: pick(
|
||
['imagespacingbottom', 'imagebottomspacing', 'imagespacebottom', 'imagebottomspace', 'imagepaddingbottom',
|
||
'paddingbottom', 'spacingbottom', 'bottomspace', 'marginbottom'],
|
||
['imagespacingbottom', 'paddingbottom', 'spacingbottom', 'bottomspacing', 'bottomspace', 'marginbottom'],
|
||
['bottom', 'bottomnum', 'paddingbottom', 'marginbottom', 'spacebottom', 'spacingbottom']
|
||
)
|
||
};
|
||
},
|
||
|
||
// 图片区域上/下内边距(作用在滚动容器上); 未配置时不输出内联样式, 沿用 CSS padding
|
||
imageSpacingStyle() {
|
||
const cfg = this.imageSpacingConfig;
|
||
let style = '';
|
||
if (cfg.paddingTop !== null) style += 'padding-top:' + cfg.paddingTop + 'rpx;';
|
||
if (cfg.paddingBottom !== null) style += 'padding-bottom:' + cfg.paddingBottom + 'rpx;';
|
||
return style;
|
||
},
|
||
|
||
// 图片项之间的横向间距; 未配置时回退原写死的 28rpx
|
||
imageItemStyle() {
|
||
const horizontal = this.imageSpacingConfig.horizontal;
|
||
return 'margin-right:' + (horizontal === null ? 28 : horizontal) + 'rpx;';
|
||
}
|
||
},
|
||
methods: {
|
||
// ========== PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22): 图片展示尺寸 ==========
|
||
// 目标: 图片按后台"上传/配置的尺寸"渲染, 不再被撑到最大(小程序/H5 表现一致)。
|
||
// 背景: 原写法直接把 item.imgWidth / item.imgHeight(每项字段)当成内联尺寸,
|
||
// 后台往往不下发这两个字段(或下发 "") → 生成 'NaNrpx' 这类非法值被忽略,
|
||
// <image> 于是回退到 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) 与 <image> 都按解析出的尺寸渲染, 两端一致;
|
||
// 样式统一由下面的方法输出"字符串"(见 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 字符串 → 整条样式直接失效:
|
||
// · 只有 <image> 时 → 图片回退到默认 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 后移除类再隐藏。
|
||
// "始终显示/永不显示"为纯 CSS 类控制(scrollbar-always/scrollbar-never), 无需 JS。
|
||
bindSlideScrollbar() {
|
||
// #ifdef H5
|
||
if (this._slideScrollBound) return;
|
||
// 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定;
|
||
// ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。
|
||
var el = this.getSlideContainer();
|
||
if (!el) return;
|
||
this._slideScrollEl = el;
|
||
this._slideScrollBound = true;
|
||
var self = this;
|
||
this._onSlideScroll = function() {
|
||
if (self.scrollBarMode != 'slide') return;
|
||
if (!self.slideScrolling) self.slideScrolling = true;
|
||
clearTimeout(self._slideScrollTimer);
|
||
self._slideScrollTimer = setTimeout(function() {
|
||
self.slideScrolling = false;
|
||
}, 800);
|
||
};
|
||
el.addEventListener('scroll', this._onSlideScroll, { passive: true });
|
||
// #endif
|
||
},
|
||
unbindSlideScrollbar() {
|
||
// #ifdef H5
|
||
if (this._slideScrollEl && this._onSlideScroll) {
|
||
this._slideScrollEl.removeEventListener('scroll', this._onSlideScroll);
|
||
}
|
||
clearTimeout(this._slideScrollTimer);
|
||
this._slideScrollEl = null;
|
||
this._onSlideScroll = null;
|
||
this._slideScrollBound = false;
|
||
this.slideScrolling = false;
|
||
// #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) {
|
||
this.$util.diyRedirectTo(link);
|
||
} else {
|
||
this.$refs.login.open(link.wap_url);
|
||
}
|
||
},
|
||
|
||
// 轮播切换
|
||
swiperChange(event) {
|
||
this.swiperCurrent = event.detail.current;
|
||
},
|
||
|
||
// ========== H5 singleSlide 鼠标拖拽支持 ==========
|
||
// 背景:uni-app 的 scroll-view 在 H5 端被编译为 div,虽然 overflow-x: auto 可让
|
||
// 浏览器渲染横向滚动条,但 scrollLeft 不响应鼠标拖拽(uni 的滚动逻辑不监听 mouse)。
|
||
// 因此 H5 下需要手动绑定 mousedown/mousemove/mouseup,模拟拖拽改变 scrollLeft。
|
||
// 同时绑定 wheel,让鼠标滚轮也能横向滚动(浏览器默认滚轮是垂直,需 preventDefault 后手动改 scrollLeft)。
|
||
// 仅在 singleSlide 模式下生效;fixed / pageSlide 走原逻辑。
|
||
//
|
||
// 关键设计:H5 专属事件全部在 mounted 中通过 addEventListener 绑定,而不是写在模板上,
|
||
// 避免小程序/鸿蒙端产生"未知事件"编译警告。所有端共用同一份模板。
|
||
bindSlideMouseEvents() {
|
||
// #ifdef H5
|
||
// 防止重复绑定
|
||
if (this._slideEventsBound) return;
|
||
// 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。
|
||
// 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生),
|
||
// 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。
|
||
// 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。
|
||
this._slideEventsBound = true;
|
||
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
||
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
||
document.addEventListener('wheel', this.onSlideWheel, { passive: false, capture: true });
|
||
// click 拦截:如果在 mousemove 中真的拖动了容器(mousedown 后 deltaX > 5),
|
||
// mouseup 时进入"忽略接下来一次 click"状态,防止拖拽结束后误触发跳转。
|
||
document.addEventListener('click', this.onSlideMaybeClick, true);
|
||
window.addEventListener('mousemove', this.onSlideMouseMove);
|
||
window.addEventListener('mouseup', this.onSlideMouseUp);
|
||
// #endif
|
||
},
|
||
unbindSlideMouseEvents() {
|
||
// #ifdef H5
|
||
document.removeEventListener('mousedown', this.onSlideMouseDown, true);
|
||
document.removeEventListener('wheel', this.onSlideWheel, true);
|
||
document.removeEventListener('click', this.onSlideMaybeClick, true);
|
||
window.removeEventListener('mousemove', this.onSlideMouseMove);
|
||
window.removeEventListener('mouseup', this.onSlideMouseUp);
|
||
this._slideOuterEl = null;
|
||
this._slideEventsBound = false;
|
||
// #endif
|
||
},
|
||
// 本组件实例的根元素(<view data-component-name="diy-image-nav">)
|
||
//
|
||
// ⚠️ 必须用 this.$el 限定在"本实例"内查找元素!
|
||
// 历史实现用 document.querySelector('[data-component-name="diy-image-nav"]'),
|
||
// 而同一个页面可以放多个滑动图组件(如 首页的 合作伙伴 + 证书资质 + 服务资质),
|
||
// document.querySelector 永远只返回页面上的**第一个**实例 ——
|
||
// 于是拖拽/滚轮第 2、3 个组件时,"指针是否在容器内"用的是第 1 个组件的位置,
|
||
// 判断不通过直接 return → 表现就是"鼠标拖不动、滚轮也没反应"。
|
||
getSlideRootEl() {
|
||
// #ifdef H5
|
||
var el = this.$el;
|
||
if (el && el.nodeType === 1) return el;
|
||
return null;
|
||
// #endif
|
||
},
|
||
// 用于判断 mousedown/wheel 是否落在容器内(bounding box)
|
||
getSlideOuterEl() {
|
||
// #ifdef H5
|
||
return this.getSlideRootEl();
|
||
// #endif
|
||
},
|
||
getSlideContainer() {
|
||
// #ifdef H5
|
||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> 自定义元素,
|
||
// 真正的滚动 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 本身(uni 内部事件重定向导致),
|
||
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
||
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._slideEl;
|
||
if (!el) return;
|
||
this.slideIsDragging = true;
|
||
this.slideStartX = x;
|
||
this.slideStartScrollLeft = el.scrollLeft;
|
||
this.slideDidDrag = false; // mousedown 时清零:5px 阈值后才置 true
|
||
this._suppressNextClick = false;
|
||
if (e.preventDefault) e.preventDefault();
|
||
// 注意:这里不调 stopPropagation,否则纯点击也不能触发 click/tap → 跳转
|
||
// #endif
|
||
},
|
||
onSlideMouseMove(e) {
|
||
// #ifdef H5
|
||
if (!this.slideIsDragging) return;
|
||
var el = this._slideEl;
|
||
if (!el) return;
|
||
var x = (e.clientX != null) ? e.clientX : 0;
|
||
var deltaX = x - this.slideStartX;
|
||
// 5px 阈值:超过后标记已经拖动,后续 mouseup 会忽略接下来一次 click
|
||
if (!this.slideDidDrag && Math.abs(deltaX) >= 5) {
|
||
this.slideDidDrag = true;
|
||
}
|
||
// 反向:手指/鼠标往右拖 → 内容向右滚 → scrollLeft 减小
|
||
el.scrollLeft = this.slideStartScrollLeft - deltaX;
|
||
if (e.preventDefault) e.preventDefault();
|
||
// #endif
|
||
},
|
||
onSlideMouseUp() {
|
||
// #ifdef H5
|
||
this.slideIsDragging = false;
|
||
// 如果确实拖动过,标记接下来一次 click 应当被拦截(防止拖拽结束后误触发跳转)
|
||
if (this.slideDidDrag) {
|
||
this._suppressNextClick = true;
|
||
}
|
||
// #endif
|
||
},
|
||
// click 拦截:如果 mouseup 时设置了 _suppressNextClick,就拦截这一次 click 并清除标记
|
||
onSlideMaybeClick(e) {
|
||
// #ifdef H5
|
||
if (!this._suppressNextClick) return;
|
||
if (!e.target) return;
|
||
var outer = this._slideOuterEl || this.getSlideOuterEl();
|
||
if (!outer || !outer.contains(e.target)) return;
|
||
this._suppressNextClick = false;
|
||
if (e.preventDefault) e.preventDefault();
|
||
if (e.stopPropagation) e.stopPropagation();
|
||
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
|
||
// #endif
|
||
},
|
||
onSlideWheel(e) {
|
||
// #ifdef H5
|
||
if (this.value.showStyle != 'singleSlide') return;
|
||
// 同 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._slideEl;
|
||
if (!el) return;
|
||
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
||
var dx = e.deltaX || 0;
|
||
var dy = e.deltaY || 0;
|
||
// 优先用 deltaX(触控板横向 / Shift+滚轮),否则用 deltaY(普通滚轮 → 转横向)
|
||
var step = dx !== 0 ? dx : dy;
|
||
el.scrollLeft += step;
|
||
if (e.preventDefault) e.preventDefault();
|
||
if (e.stopPropagation) e.stopPropagation();
|
||
// #endif
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
// 包裹 scroll-view 的容器(相对定位, 自定义滚动条绝对定位于其底部)
|
||
.image-nav-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
// 小程序端自定义水平滚动条(MP 原生 show-scrollbar 被忽略, 改用自绘,
|
||
// 与 H5 的 CSS 伪元素滚动条视觉一致; 元素仅在 #ifdef MP 下渲染)
|
||
.img-nav-scrollbar {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 2rpx;
|
||
height: 4rpx;
|
||
background-color: rgba(0, 0, 0, 0.12);
|
||
border-radius: 2rpx;
|
||
z-index: 2;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
.img-nav-scrollbar-thumb {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
height: 4rpx;
|
||
background-color: rgba(0, 0, 0, 0.4);
|
||
border-radius: 2rpx;
|
||
transition: transform 0.08s linear;
|
||
}
|
||
|
||
.image-nav {
|
||
padding: 16rpx;
|
||
box-sizing: border-box;
|
||
|
||
&.fixed-layout {
|
||
.uni-scroll-view-content {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
|
||
&.singleSlide {
|
||
.uni-scroll-view-content {
|
||
display: flex;
|
||
}
|
||
|
||
.image-nav-item {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
// ========== H5 singleSlide 鼠标拖拽支持:横向滚动 ==========
|
||
// uni-app 在 H5 端会把 scroll-view 编译为 div,但默认样式不一定是
|
||
// overflow-x:auto → 横向内容溢出后无法滚动。需要显式声明。
|
||
// #ifdef H5
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
|
||
cursor: grab;
|
||
&:active {
|
||
cursor: grabbing;
|
||
}
|
||
// 本行是 <uni-scroll-view> 自定义元素,真正产生滚动的是内部 div.uni-scroll-view,
|
||
// 这里同步声明,保证内层容器一定是"可横向滚动"的(鼠标拖拽/滚轮/触屏滑动都依赖它)
|
||
::v-deep .uni-scroll-view {
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
||
-webkit-overflow-scrolling: touch;
|
||
|
||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||
// 默认态(滑动时显示的静止态 / 永不显示): 隐藏滚动条,视觉上仍保持单页布局;
|
||
// 用户用鼠标拖拽/滚轮即可滚动(滚动能力不受滚动条显隐影响)
|
||
scrollbar-width: none; // Firefox
|
||
-ms-overflow-style: none; // IE/Edge legacy
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
// uni scroll-view 在 H5 编译后真滚动容器是内层 .uni-scroll-view, 同步隐藏
|
||
::v-deep .uni-scroll-view {
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
}
|
||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
// "始终显示" 与 "滑动时显示"(滚动中, 由 is-scrolling 类驱动): 展示细滚动条
|
||
&.scrollbar-always,
|
||
&.scrollbar-slide.is-scrolling {
|
||
scrollbar-width: thin; // Firefox
|
||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||
&::-webkit-scrollbar {
|
||
display: block;
|
||
height: 4px;
|
||
}
|
||
&::-webkit-scrollbar-thumb {
|
||
background-color: rgba(0, 0, 0, 0.25);
|
||
border-radius: 2px;
|
||
}
|
||
&::-webkit-scrollbar-track {
|
||
background-color: transparent;
|
||
}
|
||
::v-deep .uni-scroll-view {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||
}
|
||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||
display: block;
|
||
height: 4px;
|
||
}
|
||
::v-deep .uni-scroll-view::-webkit-scrollbar-thumb {
|
||
background-color: rgba(0, 0, 0, 0.25);
|
||
border-radius: 2px;
|
||
}
|
||
::v-deep .uni-scroll-view::-webkit-scrollbar-track {
|
||
background-color: transparent;
|
||
}
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
&.pageSlide {
|
||
position: relative;
|
||
|
||
.uni-swiper-dots-horizontal {
|
||
bottom: 0rpx;
|
||
}
|
||
|
||
&.straightLine {
|
||
.uni-swiper-dot {
|
||
width: 30rpx;
|
||
border-radius: 0;
|
||
height: 8rpx;
|
||
}
|
||
}
|
||
|
||
&.circle {
|
||
.uni-swiper-dot {
|
||
width: 14rpx;
|
||
height: 14rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.image-nav-wrap {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.image-nav-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 14rpx 0;
|
||
box-sizing: border-box;
|
||
|
||
.image-text {
|
||
padding-top: 12rpx;
|
||
line-height: 1.5;
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
text-align: center;
|
||
|
||
&.alone {
|
||
padding-top: 0;
|
||
}
|
||
}
|
||
|
||
&.text {
|
||
.image-text {
|
||
padding-top: 0;
|
||
}
|
||
}
|
||
|
||
.image-img {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 90rpx;
|
||
overflow: hidden;
|
||
|
||
// PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
||
// 兜底夹住图片尺寸(不设 width/height:100%, 避免外层盒尺寸异常时高度塌成 0
|
||
// 导致图片完全不显示)。正常尺寸由 imageInnerStyle 输出的内联样式决定。
|
||
.image-content {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
}
|
||
|
||
.tag {
|
||
position: absolute;
|
||
top: -10rpx;
|
||
right: -24rpx;
|
||
color: #fff;
|
||
border-radius: 24rpx;
|
||
border-bottom-left-radius: 0;
|
||
-webkit-transform: scale(0.8);
|
||
transform: scale(0.8);
|
||
padding: 8rpx 16rpx;
|
||
line-height: 1;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.icon {
|
||
font-size: 50rpx;
|
||
color: #606266;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.swiper-dot-box {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: -20rpx;
|
||
padding-bottom: 8rpx;
|
||
|
||
.swiper-dot {
|
||
background-color: rgba(0, 0, 0, 0.3);
|
||
margin: 8rpx;
|
||
|
||
&.active {
|
||
background-color: #000;
|
||
}
|
||
}
|
||
|
||
&.straightLine {
|
||
.swiper-dot {
|
||
width: 30rpx;
|
||
border-radius: 0;
|
||
height: 8rpx;
|
||
}
|
||
}
|
||
|
||
&.circle {
|
||
.swiper-dot {
|
||
width: 15rpx;
|
||
border-radius: 50%;
|
||
height: 15rpx;
|
||
}
|
||
}
|
||
}
|
||
</style> |