764 lines
28 KiB
Vue
764 lines
28 KiB
Vue
<template>
|
||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
|
||
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
|
||
小程序端: show-scrollbar 仅在 enhanced=true 时生效, 所以两者同步绑定 -->
|
||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
|
||
:style="imageSpacingStyle"
|
||
:scroll-x="value.showStyle == 'singleSlide'"
|
||
:show-scrollbar="scrollBarMode != 'never'"
|
||
:enhanced="scrollBarMode != 'never'">
|
||
<view class="uni-scroll-view-content">
|
||
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||
:style="imageItemStyle">
|
||
<!-- 图片部分 -->
|
||
<view v-if="value.mode != 'text'" class="image-img" :style="{
|
||
'font-size': (value.imageSize * 2 + 'rpx') + ';',
|
||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||
}">
|
||
<image v-if="item.link.wap_url" :style="{
|
||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||
}" :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 :style="{
|
||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||
}" :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>
|
||
<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
|
||
}
|
||
},
|
||
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
|
||
},
|
||
beforeDestroy() {
|
||
// #ifdef H5
|
||
this.unbindSlideMouseEvents();
|
||
this.unbindSlideScrollbar();
|
||
// #endif
|
||
},
|
||
watch: {
|
||
componentRefresh(newValue) {
|
||
// 监听组件刷新
|
||
}
|
||
},
|
||
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;
|
||
},
|
||
|
||
// ========== 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-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;
|
||
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
|
||
},
|
||
|
||
// 页面跳转
|
||
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;
|
||
var outerEl = this.getSlideOuterEl();
|
||
var innerEl = this.getSlideContainer();
|
||
if (!outerEl || !innerEl) return;
|
||
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
|
||
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
|
||
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
|
||
this._slideOuterEl = outerEl;
|
||
this._slideEl = innerEl;
|
||
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
|
||
},
|
||
getSlideContainer() {
|
||
// #ifdef H5
|
||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
|
||
// 嵌套结构:
|
||
// <uni-scroll-view> ← 自定义元素
|
||
// <div class="uni-scroll-view"> ← 第1层,visible
|
||
// <div class="uni-scroll-view"> ← 第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;
|
||
// #endif
|
||
},
|
||
getSlideOuterEl() {
|
||
// #ifdef H5
|
||
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 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;
|
||
// #endif
|
||
},
|
||
onSlideMouseDown(e) {
|
||
// #ifdef H5
|
||
if (this.value.showStyle != 'singleSlide') return;
|
||
// document 级别监听,只处理落在容器内的事件
|
||
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
|
||
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
||
if (!this._slideOuterEl) 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();
|
||
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.getSlideContainer();
|
||
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;
|
||
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;
|
||
if (!this._slideOuterEl) return;
|
||
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
|
||
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();
|
||
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>
|
||
.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;
|
||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||
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;
|
||
|
||
.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> |