Files
lucky_shop/components-diy/diy-article.vue
ZF sun 7a76824fcf fix(diy-article, diy-image-nav): 修复H5端拖拽后误触发跳转的问题
1.  重构两个组件的H5端鼠标事件绑定逻辑,改用document级别capture监听解决uni-app自定义元素事件拦截问题
2.  添加拖拽阈值检测,避免点击误触发滚动
3.  新增click拦截逻辑,拖拽结束后自动屏蔽后续一次click事件防止误跳转
4.  优化滚动容器获取方式,兼容uni-app编译后的嵌套DOM结构
2026-07-11 10:03:23 +08:00

1545 lines
61 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<x-skeleton data-component-name="diy-article" type="list" :loading="loading" :configs="skeletonConfig">
<view class="article-wrap" :style="warpWrapStyle">
<!-- 注: :class 用内联数组,与 ns-newform 对齐;computed listClass/itemClass 已移除(避免数组 toString 用逗号 join 的问题) -->
<view :class="['list-wrap', value.style || 'style-1']">
<!-- 自动垂直滚动(JS 驱动 transform 跑马灯,默认开启,同时支持用户手动拖拽)
设计:loopedList 把 list 复制成 A+A 两份;外层容器固定高度(窗口)overflow:hidden,
内层容器用 JS 控制 transform:translateY,从 0 → -第一份高度后无缝重置。
支持:① 自动连续/步进滚动;② 用户触摸并拖拽可手动滚动;③ 松手 2s 后从当前位置恢复自动滚动。 -->
<view v-if="list.length > 0 && useScroll"
class="auto-scroll-list"
:style="autoScrollListStyle"
@touchstart="onMarqueeTouchStart"
@touchmove.stop.prevent="onMarqueeTouchMove"
@touchend="onMarqueeTouchEnd">
<view class="marquee-track" :style="marqueeTrackStyle">
<view v-for="(item, index) in loopedList" :key="index"
:class="['item', value.ornament && value.ornament.type, !item.cover_img && value.style !== 'style-4' ? 'no-image' : '']"
:style="itemStyle"
@tap.stop="toDetail(item)">
<!-- 封面图:无图时隐藏图位;style-4 纯文字始终不显示 -->
<view class="article-img" v-if="item.cover_img && value.style !== 'style-4'">
<image class="cover-img" :src="$util.img(item.cover_img)" mode="aspectFill"
@error="imgError(index % list.length)" show-menu-by-longpress></image>
</view>
<view class="info-wrap">
<text class="title">{{ item.article_title }}</text>
<text class="desc"
v-if="abstractStyle.support && abstractStyle.control && item.article_abstract">
{{ item.article_abstract }}
</text>
<view class="tag-list"
v-if="tagStyle.support && tagStyle.control && item.tag_names">
<text class="tag" v-for="(tn, ti) in tagArr(item.tag_names)" :key="ti">{{ tn }}</text>
</view>
<view class="read-wrap">
<block v-if="item.category_name">
<text class="category-icon"></text>
<text class="category-name">{{ item.category_name }}</text>
</block>
<view class="num" v-if="readNumStyle.support && readNumStyle.control">
<uni-icons type="eye" size="12" :color="metaColor" />
<text>{{ _humanizeNum(item.read_num || 0) }}</text>
</view>
<view class="num" v-if="dianzanStyle.support && dianzanStyle.control">
<uni-icons type="star" size="12" :color="metaColor" />
<text>{{ _humanizeNum(item.dianzan_num || 0) }}</text>
</view>
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 非滚动模式(scrollMode==='none')时的普通列表 -->
<template v-if="!useScroll">
<view v-for="(item, index) in list" :key="index" :class="['item', value.ornament && value.ornament.type, !item.cover_img && value.style !== 'style-4' ? 'no-image' : '']" :style="itemStyle"
@tap.stop="toDetail(item)">
<view class="article-img" v-if="item.cover_img && value.style !== 'style-4'">
<image class="cover-img" :src="$util.img(item.cover_img)" mode="aspectFill"
@error="imgError(index)" show-menu-by-longpress></image>
</view>
<view class="info-wrap">
<text class="title">{{ item.article_title }}</text>
<text class="desc"
v-if="abstractStyle.support && abstractStyle.control && item.article_abstract">
{{ item.article_abstract }}
</text>
<view class="tag-list"
v-if="tagStyle.support && tagStyle.control && item.tag_names">
<text class="tag" v-for="(tn, ti) in tagArr(item.tag_names)" :key="ti">{{ tn }}</text>
</view>
<view class="read-wrap">
<block v-if="item.category_name">
<text class="category-icon"></text>
<text class="category-name">{{ item.category_name }}</text>
</block>
<view class="num" v-if="readNumStyle.support && readNumStyle.control">
<uni-icons type="eye" size="12" :color="metaColor" />
<text>{{ _humanizeNum(item.read_num || 0) }}</text>
</view>
<view class="num" v-if="dianzanStyle.support && dianzanStyle.control">
<uni-icons type="star" size="12" :color="metaColor" />
<text>{{ _humanizeNum(item.dianzan_num || 0) }}</text>
</view>
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
</view>
</view>
</view>
<!-- 查看更多(仅非滚动模式显示) -->
<view class="view-more" v-if="viewMore.support && viewMore.control && list.length > 0"
:style="viewMoreStyle" @tap.stop="toListPage">
<text>{{ viewMore.text || $lang('components-diy.diy-article.viewMore') }}</text>
<uni-icons type="arrowright" size="12" :color="viewMore.color" />
</view>
</template>
<view v-if="list.length === 0 && !loading" class="empty-placeholder">
<text class="empty-text">{{ $lang('components-diy.diy-article.noArticles') }}</text>
</view>
</view>
</view>
</x-skeleton>
</template>
<script>
// 依赖 ./minx.js 提供 componentRefresh、$util 等装修通用能力
import DiyMinx from './minx.js';
// 文章
export default {
name: 'diy-article',
props: {
value: {
type: Object,
default: () => ({})
}
},
mixins: [DiyMinx],
data() {
return {
list: [],
loading: true,
skeletonConfig: {
gridRows: 1,
gridRowsGap: '40rpx',
headWidth: '160rpx',
headHeight: '160rpx',
textRows: 2
},
// ========== 跑马灯状态(JS 驱动 transform,同时支持用户手动拖拽)==========
// marqueeResumeTimer: touchend 后延迟恢复自动滚动
marqueeResumeTimer: null,
// user 拖拽状态
isUserDragging: false,
dragStartY: 0,
// H5 端:标记 mousedown 已成功触发(mousemove 在没有这个标记时忽略)
_mouseDownActive: false,
// H5 端:标记 mousedown 后是否真的拖拽超过 5px 阈值
// 拖拽结束后用来判断 mouseup 后是否需要屏蔽 click(防止 uni-app @tap.stop 误触发跳转)
_mouseDidDrag: false,
_suppressNextClick: false,
dragStartTranslateY: 0,
// ========== 连续滚动模式字段(marqueeMode === 'continuous')==========
// 驱动 transform: translateY 在 A+A 双份内容的 track 上连续滚动
continuousTranslateY: 0,
continuousRAF: null,
continuousLastTime: 0,
// ========== 步进模式字段(stepPause=true 时启用)==========
// 驱动 transform: translateY 在 A+A 双份内容的 track 上做 item-by-item 滚动
// 注意: 自定义字段不以 _ 开头,避免 Vue 2 不代理到 this 上导致 "is not a function"
stepIndex: 0,
stepTimer: null,
stepTranslateY: 0,
isStepSnapping: false
};
},
created() {
this.getList();
},
mounted() {
// H5 端:为 .auto-scroll-list 容器手动绑定 mousedown/wheel 事件
// 原因:模板上写 @mousedown/@wheel 在小程序/鸿蒙端会产生"未知事件"编译警告。
// 所有端公用同一份模板,H5 专属事件通过 mounted 中的 addEventListener 绑定。
// 注意:.auto-scroll-list 是 v-if 渲染的(list 加载完后才出现),
// 实际绑定在 'list.length' watcher 的 nextTick 中。这里调用一次
// 只是为了兼容首屏 mounted 时 list 已有数据的场景(HMR / SSR 缓存)。
// #ifdef H5
var self = this;
this.$nextTick(function() {
self.bindMarqueeMouseEvents();
});
// #endif
},
beforeDestroy() {
// 直接清理所有定时器/动画帧,避免在某些场景(如 HMR)下调用以 _ 开头方法时出现
// "is not a function" 的问题
this.stopAllMarquee();
// #ifdef H5
this.unbindMarqueeMouseEvents();
// #endif
},
watch: {
// 组件刷新监听
componentRefresh() {
this.getList();
},
// list 加载完后,根据当前模式启动滚动
'list.length'(newLen) {
if (newLen > 1 && this.useScroll) {
var self = this;
this.$nextTick(function() {
if (self.marqueeMode === 'step') {
self.startStepMarquee();
} else {
self.startContinuousMarquee(true);
}
// H5 端:.auto-scroll-list 是 v-if 渲染的,首屏 mounted 时可能 list 还是空,
// 所以要在 list 加载完后才真正绑定鼠标事件(避免 refs 拿不到)
// #ifdef H5
self.bindMarqueeMouseEvents();
// #endif
});
}
},
// 模式切换(后端修改 stepPause 后):停止旧的动画,按需重启动
marqueeMode(newMode) {
this.stopAllMarquee();
if (!this.useScroll || !this.list || this.list.length <= 1) return;
var self = this;
this.$nextTick(function() {
if (newMode === 'step') {
self.startStepMarquee();
} else {
self.startContinuousMarquee(true);
}
});
}
},
computed: {
// ========== 容器样式(与 ns-newform 对齐: $util.objToStyle + CSS 变量) ==========
// 根容器:背景 + CSS 变量(向下传递,H5/MP/Harmony 均支持继承)
// 注: 用 objToStyle 自动过滤 undefined/空值,避免输出 background-color:; 这类脏 CSS
warpWrapStyle() {
const v = this.value || {};
// ========== 单位约定 ==========
// 数据里所有「字号/图标/间距/圆角」类数字字段统一存为 rpx(uni-app 响应式像素);
// 老数据若是字符串("24rpx")或缺字段,经 r() 归一化为数字,再拼 'rpx' 后缀;
// 老数据若用 px 存(早期 DIY 圆角),需在设计面板里手动改为 rpx 量级再保存(否则会被 r() 截断)。
// 后端预览(PC 浏览器)做 /2 折算,把 rpx 转 px。
const r = (val, fb) => this._num(val, fb);
const isRootRound = v.componentAngle === 'round';
const isItemRound = v.elementAngle === 'round';
// 标签胶囊 3 色 + 字号(默认值与后端 design.js nestedDefaults 对齐)
const tag = v.tagStyle || {};
// 摘要文字色 + 字号
const abs = v.abstractStyle || {};
// 查看更多(字号 + 图标大小)
const vm = v.viewMore || {};
// 阅读区(文字色 + 图标大小 + 文字大小)
const rw = v.readWrapStyle || {};
// 标题字号(每风格独立 1/2/3/4)
const tfs1 = r(v.titleFontSize1, 30);
const tfs2 = r(v.titleFontSize2, 30);
const tfs3 = r(v.titleFontSize3, 24);
const tfs4 = r(v.titleFontSize4, 28);
return this.$util.objToStyle({
'background-color': v.componentBgColor || 'transparent',
// CSS 变量(向下传递给所有子元素,通过 var() 读取)
'--article-component-bg-color': v.componentBgColor || 'transparent',
'--article-element-bg-color': v.elementBgColor || 'transparent',
// ========== 关键:内联 style 的 rpx 在 H5 端浏览器不识别! ==========
// MP-WXSS 原生支持 rpx,内联 rpx 有效;H5 端 rpx 不是有效 CSS 单位,
// 直接写 "280rpx" 浏览器会忽略,导致 CSS 变量 / swiper 高度全失效。
// 全部改走 this._rpx(val),H5 转 vw(750rpx 设计宽度 → 100/750 vw),
// 其他端保留 rpx,与 CSS 文件(走 postcss 转换)对齐。
'--article-top-around-radius': this._rpx(isRootRound ? r(v.topAroundRadius, 0) * 2 : 0),
'--article-bottom-around-radius': this._rpx(isRootRound ? r(v.bottomAroundRadius, 0) * 2 : 0),
'--article-top-element-around-radius': this._rpx(isItemRound ? r(v.topElementAroundRadius, 0) * 2 : 0),
'--article-bottom-element-around-radius': this._rpx(isItemRound ? r(v.bottomElementAroundRadius, 0) * 2 : 0),
// 标签胶囊 3 色 + 字号(rpx)
'--article-tag-bg-color': tag.bgColor || '#f0f9ff',
'--article-tag-text-color': tag.textColor || '#1e6bb8',
'--article-tag-border-color': tag.borderColor || '#b3d8ff',
'--article-tag-font-size': this._rpx(r(tag.fontSize, 22)),
// 摘要文字色 + 字号(rpx)
'--article-abstract-color': abs.textColor || '#888888',
'--article-abstract-font-size': this._rpx(r(abs.fontSize, 24)),
// 阅读区文字色 + 图标大小 + 文字大小(rpx)
'--article-meta-color': rw.color || '#999ca7',
'--article-meta-icon-size': this._rpx(r(rw.iconSize, 24)),
'--article-meta-font-size': this._rpx(r(rw.fontSize, 24)),
// 查看更多字号 + 图标大小(rpx,统一走 r() 兜底,不再 typeof 区分)
'--article-view-more-font-size': this._rpx(r(vm.fontSize, 26)),
'--article-view-more-icon-size': this._rpx(r(vm.iconSize, 24)),
// 列表项间距(rpx)— H5 端此值若失效,.swiper-item-content padding-bottom 变 0,item 直接贴一起
'--article-item-gap': this._rpx(r(v.itemGap, 16)),
// swiper 内 item 统一最小高度(rpx)= list 中最高 item 的自然高度
// CSS 中 .auto-scroll-swiper .item { min-height: var(--article-swiper-item-min-height) }
// 强制所有 item 高度统一,搭配 .swiper-item-content 的 padding-bottom = itemGap
// 制造恒定视觉间距,与 swiperHeight = (maxItemHeight + itemGap) × N 公式一致
// H5 端此值若失效,fallback 200rpx 会让 item 高度统一卡死在 200rpx,有摘要 item 溢出被裁
'--article-swiper-item-min-height': this._rpx(this.maxItemHeight),
// 分割线颜色(主要用于 style-4)
'--article-divider-color': v.dividerColor || '#eeeeee',
// 标题字号 4 风格独立(rpx)
'--article-title-fs-1': this._rpx(tfs1),
'--article-title-fs-2': this._rpx(tfs2),
'--article-title-fs-3': this._rpx(tfs3),
'--article-title-fs-4': this._rpx(tfs4)
});
},
// 单项容器样式:背景 + 装饰(box-shadow / border)
// 注: 单项的 border-radius 由 CSS 变量向下传递(无需重复声明)
itemStyle() {
const v = this.value || {};
const ornament = v.ornament || {};
const style = {
'background-color': v.elementBgColor || 'transparent'
};
if (ornament.type === 'shadow') {
// H5 端 rpx 失效,改走 _rpx()。10/2 是阴影模糊半径/边框宽度,与字号/间距共用同一单位
style['box-shadow'] = `0 0 ${this._rpx(10)} ${ornament.color || 'rgba(0, 0, 0, 0.1)'}`;
}
if (ornament.type === 'stroke') {
style['border'] = `${this._rpx(2)} solid ${ornament.color || 'transparent'}`;
}
return this.$util.objToStyle(style);
},
// 滚动容器高度(scroll-view 用)
// 语义:窗口显示「前 N 个 item 的实际自然高度」,严格匹配后端 displayMultipleItems 配置
// N = safeDisplayMultipleItems
// 高度 = sum(前 N 个 item 的 _itemHeight) + (N-1) × itemGap
// 之前用 maxItemHeight(全 list 最高一件) × N + buffer:短 item 多塞,长 item 溢出,可见行数随内容漂移
// 现在:前 N 个 item 的真实高度之和 → 窗口里就是 N 行,所见即所配
// 边界:list 尚未加载时(list 空)走 _staticMaxItemHeight() × N 兜底,保持初始可见区域
autoScrollListStyle() {
return this.$util.objToStyle({
height: this._rpx(this._visibleWindowHeight)
});
},
// 可见窗口高度(rpx) — 即 scroll-view 容器高度
_visibleWindowHeight() {
if (!this.list || this.list.length === 0) {
// 兜底:用静态最高 item × N 估算,保证初始有可见区域
return this._staticMaxItemHeight() * this.safeDisplayMultipleItems;
}
var n = this.safeDisplayMultipleItems;
var gap = this._num(this.value.itemGap, 16);
// 取前 N 个 item 的自然高度之和(list 不足 N 时全取)
var count = Math.min(n, this.list.length);
var sum = 0;
for (var i = 0; i < count; i++) {
sum += this._itemHeight(this.list[i]);
}
return sum + (count - 1) * gap;
},
// 查看更多样式(颜色由 viewMore.color 控制)
viewMoreStyle() {
return this.$util.objToStyle({
color: (this.viewMore && this.viewMore.color) || '#999999'
});
},
// 阅读区文字色(供 <uni-icons> 复用,确保 icon 颜色随 readWrapStyle.color 联动)
metaColor() {
return (this.value && this.value.readWrapStyle && this.value.readWrapStyle.color) || '#999ca7';
},
// ========== 字段显示配置(带默认值,避免老数据缺字段时报错) ==========
abstractStyle() {
return Object.assign({ support: true, control: true, fontSize: 24 }, this.value.abstractStyle || {});
},
readNumStyle() {
return Object.assign({ support: true, control: true }, this.value.readNumStyle || {});
},
dianzanStyle() {
return Object.assign({ support: true, control: false }, this.value.dianzanStyle || {});
},
dateStyle() {
return Object.assign({ support: true, control: true }, this.value.dateStyle || {});
},
tagStyle() {
return Object.assign({ support: true, control: true, fontSize: 22 }, this.value.tagStyle || {});
},
viewMore() {
// control 默认 true 与后端 design.js + diy_view.php 对齐(老数据没 viewMore 时也走显示)
return Object.assign({ support: true, control: true, text: '', color: '#999999', iconSize: 24 },
this.value.viewMore || {});
},
// 阅读区样式(文字色 + 图标大小 + 文字大小)
readWrapStyle() {
return Object.assign({ color: '#999ca7', iconSize: 24, fontSize: 24 },
this.value.readWrapStyle || {});
},
// ========== 滚动配置(兼容老数据 swiperConfig;现在用 CSS 跑马灯实现)==========
// scrollMode: 'none' 关闭滚动,显示普通列表;其他值均走 CSS 跑马灯
swiperConfig() {
const v = this.value || {};
return v.swiperConfig || {};
},
scrollMode() {
const v = this.value || {};
return v.scrollMode || 'vertical';
},
// 是否使用滚动模式
useScroll() {
return this.scrollMode !== 'none' && this.list.length > 1;
},
// 安全的 display-multiple-items(避免窗口高度 > 内容高度时出现空白)
safeDisplayMultipleItems() {
var displayCount = this.swiperConfig.displayMultipleItems || 3;
var actualCount = this.list.length;
return actualCount > 0 ? Math.min(displayCount, actualCount) : 1;
},
// 跑马灯动画时长(秒)
// ① 若 swiperConfig.marqueeDuration 显式传,直接使用(秒,允许小数)
// ② 否则根据 list 长度估算:每 1 条 item 走 4 秒(慢速,阅读友好,≈ 15-30rpx/s)
// 为什么不用 _itemHeight 算: _itemHeight 是 rpx 估算值,H5 端换算成 vw 后再
// 与 CSS 动画的秒数匹配很麻烦;采用"按条数线性时长"更跨端一致,且阅读节奏可控。
marqueeDuration() {
var cfg = this.swiperConfig || {};
var explicit = parseFloat(cfg.marqueeDuration);
if (!isNaN(explicit) && explicit > 0) return explicit;
var n = (this.list && this.list.length) || 0;
// 每 1 条 4 秒;最少 8 秒(避免 2 条时飞快)
return Math.max(8, n * 4);
},
// 跑马灯动画起点延迟(默认 1 秒,给首条内容 1 秒阅读时间)
marqueeDelay() {
var cfg = this.swiperConfig || {};
var explicit = parseFloat(cfg.marqueeDelay);
if (!isNaN(explicit) && explicit >= 0) return explicit;
return 1;
},
// ========== 步进模式配置(与后端 design.js nestedDefaults 对齐)==========
// stepPause=true 时,一条一条滚动,每条之间停 pauseDuration 毫秒;
// 每条滚动动画耗时 stepAnimDuration 毫秒。
marqueeMode() {
return this.stepPauseEnabled ? 'step' : 'continuous';
},
stepPauseEnabled() {
return !!this.swiperConfig.stepPause;
},
pauseDuration() {
var d = parseInt(this.swiperConfig.pauseDuration);
return d > 0 ? d : 1500;
},
stepAnimDuration() {
var d = parseInt(this.swiperConfig.stepAnimDuration);
return d > 0 ? d : 500;
},
// 内层 .marquee-track 的 inline style
// 统一用 JS 控制 transform: translateY + transition;不再使用 CSS @keyframes,
// 以便用户触摸拖拽时能即时改变滚动位置,并从当前位置恢复自动滚动。
marqueeTrackStyle() {
var translateY = this.marqueeMode === 'step' ? this.stepTranslateY : this.continuousTranslateY;
// 步进自动滚动时启用 transition 产生动画;拖拽中/snapping 时禁用,保证即时响应/无跳变
var transition = 'none';
if (this.marqueeMode === 'step' && !this.isUserDragging && !this.isStepSnapping) {
transition = 'transform ' + this.stepAnimDuration + 'ms ease-in-out';
}
return this.$util.objToStyle({
'animation': 'none',
'transform': 'translateY(-' + this._rpx(translateY) + ')',
'transition': transition
});
},
// list 中最高 item 的自然高度(动态) — swiperHeight + warpWrapStyle CSS 变量都用它
// list 为空时走 _staticMaxItemHeight 兜底
maxItemHeight() {
if (!this.list || this.list.length === 0) {
return this._staticMaxItemHeight();
}
return Math.max.apply(null, this.list.map(function (item) {
return this._itemHeight(item);
}.bind(this)));
},
// ========== scroll-view 自动滚动 配套 computed ==========
// 滚动列表(复制一份用于无缝循环):scrollTop 滚到第一份底部时跳回 0,视觉上接续
loopedList() {
if (!this.list || this.list.length === 0) return [];
return this.list.concat(this.list);
},
// 单份 list 的总高度(rpx)= sum(各 item 自然高) + (N-1) × itemGap
// 重置 scrollTop 用:当 scrollTop 累加到 ≥ 此值,说明已经滚完第一份,跳回 0 视觉无感
firstListHeight() {
if (!this.list || this.list.length === 0) return 0;
var gap = this._num(this.value.itemGap, 16);
var sum = 0;
for (var i = 0; i < this.list.length; i++) {
sum += this._itemHeight(this.list[i]);
}
return sum + (this.list.length - 1) * gap;
},
// item 索引 i 的累计顶部偏移(rpx)= sum(items[0..i-1].height) + i × gap
// 步进模式用:把 scrollTop 推到 itemOffset(i) 即「item i 顶部贴窗口顶部」
// loopedList[i+N] 位置 = firstListHeight + itemOffset(i),即「第一份末尾之后接续」
// 注意: computed 名不以 _ 开头,避免 Vue 2 不代理到 this 上
itemOffsetArr() {
if (!this.list || this.list.length === 0) return [];
var gap = this._num(this.value.itemGap, 16);
var arr = [];
var sum = 0;
for (var i = 0; i < this.list.length; i++) {
arr.push(sum);
sum += this._itemHeight(this.list[i]) + gap;
}
return arr;
},
// 注: :class 一律在模板里用 :class="['static', dynamic]" 内联数组写法
// 不再用 listClass/itemClass computed (computed 返回 array 时,uni-app WXML 编译
// 输出 {{[listClass]}},运行时 toString 用逗号 join,产生 "list-wrap,style-1" 错误 class)
},
methods: {
// 数字保底:与 diy-form._num 对齐(undefined/空串/非数字时返回兜底值)
_num(v, fb) {
if (v === '' || v === null || v === undefined) return fb;
var n = parseInt(v);
return isNaN(n) ? fb : n;
},
// 内联 style 单位归一化:H5 端 rpx 不是合法 CSS 单位,转 vw;其他端保留 rpx
// 设计宽度按 750rpx(uni-app 默认),转换 1rpx = 100/750 vw
// 关键:这个方法只用于内联 :style 绑定,CSS 文件里的 rpx 走 postcss 转换,不用管
_rpx(val) {
// #ifdef H5
return (val * 100 / 750) + 'vw';
// #endif
// #ifndef H5
return val + 'rpx';
// #endif
},
// ========== 动态 perRow 估算(swiperHeight 用) ==========
// 估算单条 item 的自然高度(rpx),考虑:
// - style 决定图片高度 / info-wrap 宽度 / padding
// - 是否带图(with-image: max(图片, 内容) + padding; no-image: 走 CSS min-height 240)
// - 标题/摘要实际字数(按 1 char ≈ 1*fontSize 估算行数,clamp 1 或 2)
// - 标签开关 + 数量(按每行 ~3 字 + padding 估算,可能 1/2 行)
// - 阅读区(默认 1 行)
// 返回值:该 item 的总高度(rpx)
_itemHeight(item) {
if (!item) return 200;
var v = this.value || {};
var style = v.style || 'style-1';
var hasImg = !!item.cover_img && style !== 'style-4';
var padding = style === 'style-3' ? 20 : 40;
// 图片高度
var imageH = 0;
if (hasImg) {
if (style === 'style-1') imageH = 160;
else if (style === 'style-2') imageH = 320;
else if (style === 'style-3') imageH = 200;
}
// info-wrap 宽度(style-1 有图时图占左侧 160+margin 20+padding 40 → 530;其余 750-padding)
var infoW = (style === 'style-1' && hasImg) ? 530 : (750 - padding);
// 标题(style-4 是 nowrap 单行;其余 clamp 2)
var titleFs = this._titleFsForStyle(style);
var titleMaxLines = style === 'style-4' ? 1 : 2;
var titleLines = this._estimateLines(item.article_title, titleFs, infoW, titleMaxLines);
var titleH = titleLines * titleFs * 1.4;
// 摘要
var abstractH = 0;
if (v.abstractStyle && v.abstractStyle.support && v.abstractStyle.control && item.article_abstract) {
var absFs = (v.abstractStyle && v.abstractStyle.fontSize) || 24;
var absLines = this._estimateLines(item.article_abstract, absFs, infoW, 2);
abstractH = absLines * absFs * 1.4;
}
// 标签
var tagH = 0;
if (v.tagStyle && v.tagStyle.support && v.tagStyle.control && item.tag_names) {
var tags = (item.tag_names || '').split(',').filter(Boolean);
if (tags.length > 0) {
var tagFs = (v.tagStyle && v.tagStyle.fontSize) || 22;
// 标签宽度估算:每标签按 3 字 + 内边距 28(14+14)+ 边框 4 = 3*tagFs + 32
var tagW = 3 * tagFs + 32;
var tagsPerLine = Math.max(1, Math.floor(infoW / tagW));
var tagLines = Math.ceil(tags.length / tagsPerLine);
// 标签自身:行高 ≈ tagFs + 2*padding 4 + margin-bottom 4 = tagFs + 12
tagH = tagLines * (tagFs + 12);
}
}
// 阅读区(默认 1 行,24rpx;不细分开关以保持公式简洁)
var readH = 24;
// 各元素之间的 margin-top(10rpx):有摘要 / 有标签 / 阅读区各算一个
var margins = (abstractH > 0 ? 10 : 0) + (tagH > 0 ? 10 : 0) + 10;
var contentH = titleH + abstractH + tagH + readH + margins;
// 拼装 item 高度
if (style === 'style-1') {
if (hasImg) {
// 图左文右:item 高度 = max(图, 内容) + padding
return Math.max(imageH, contentH) + padding;
} else {
// 无图:CSS .item.no-image .info-wrap { min-height: 160rpx } 兜底,
// 让 read-wrap 在 space-between 下被推到 160 起点 + read 高度处,
// 与有图短内容场景(read-wrap 推到 160 起点)Y 对齐
return Math.max(160, contentH) + padding;
}
}
// style-2/3:上图下文,item 高度 = 图 + 内容 + padding
return imageH + contentH + padding;
},
// 估算 text 在指定字号 + 容器宽度下占几行
// 启发式:1 char ≈ 1 * fontSize 宽度(中文为主场景,英文场景略多行但不溢出)
// 返回值范围:0 到 maxLines
_estimateLines(text, fontSize, containerWidth, maxLines) {
if (!text) return 0;
var chars = String(text).length;
var charsPerLine = Math.max(1, Math.floor(containerWidth / fontSize));
return Math.min(maxLines, Math.max(1, Math.ceil(chars / charsPerLine)));
},
// 标题字号(4 风格独立)
_titleFsForStyle(style) {
var v = this.value || {};
if (style === 'style-1') return v.titleFontSize1 || 30;
if (style === 'style-2') return v.titleFontSize2 || 30;
if (style === 'style-3') return v.titleFontSize3 || 24;
if (style === 'style-4') return v.titleFontSize4 || 28;
return 30;
},
// list 为空时的兜底 maxItemHeight(全字段最坏情况,与 swiperHeight 公式配合:
// swiperHeight = (maxItemHeight + itemGap) × N)
// style-1: 有图 = max(160,content) + 40;无图 = max(160,content) + 40;全字段最坏 = 280
// 注:有图/无图都用 min-height 160 兜底,所以 _itemHeight 公式统一取 max(160, contentH) + padding
_staticMaxItemHeight() {
var style = this.value.style;
switch (style) {
case 'style-1': return 280; // 有图273/无图240(短内容)/全字段最坏280
case 'style-2': return 584; // 图320+info 224+padding40
case 'style-3': return 316; // 图200+info 96+padding20
case 'style-4': return 104; // 标题28+read24+5margin+40padding+8buffer
default: return 280;
}
},
// 把逗号分隔的 tag_names 转为数组(过滤空)
tagArr(str) {
return (str || '').split(',').filter(Boolean);
},
// 数字人性化:千/万单位(中文 app 主流写法)
// < 1000: 原样("123")
// 1000-9999: 转 k,保留 1 位小数,整数小数位去掉("1k" / "1.2k")
// ≥ 10000: 转 万,规则同上("1万" / "1.2万" / "12.3万" / "1234万")
// 非数字/负数/NaN: 返回原值(防御性,后端偶发返回 string/0/负数时不崩)
// 为什么不直接 toLocaleString('zh-CN'):toLocaleString 在 H5 端正常,但在 MP-WXSS / Harmony 上
// 对 zh-CN locale 支持不一致,有的会把 12000 渲染成 "12,000" 有的渲染成 "12,000.00" —
// 跨端不稳。手写 if-else 跨端一致。
_humanizeNum(n) {
var num = Number(n);
if (!isFinite(num) || num < 0) return n;
if (num < 1000) return String(num);
if (num < 10000) {
// 千位:num / 1000,保留 1 位小数,去掉无意义的 .0
var k = (num / 1000).toFixed(1);
return (k.endsWith('.0') ? k.slice(0, -2) : k) + 'k';
}
// 万位:num / 10000
var w = (num / 10000).toFixed(1);
return (w.endsWith('.0') ? w.slice(0, -2) : w) + 'w';
},
getList() {
this.loading = true;
var data = {
num: this.value.count || 8,
sort_way: this.value.sortWay || 'latest'
};
var sources = this.value.sources || 'initial';
if (sources === 'diy') {
data.num = 0;
data.article_id_arr = (this.value.articleIds || []).toString();
} else if (sources === 'category' && this.value.categoryId) {
data.category_id = this.value.categoryId;
} else if (sources === 'tag' && this.value.tagId) {
data.tag_id = this.value.tagId;
}
this.$api.sendRequest({
url: '/api/article/lists',
data: data,
success: res => {
this.list = (res.code == 0 && res.data) ? res.data : [];
this.loading = false;
},
fail: () => {
this.list = [];
this.loading = false;
}
});
},
toDetail(item) {
this.$util.redirectTo('/pages_tool/article/detail', {
article_id: item.article_id
});
},
// "查看更多" 跳列表页(若按分类/按标签则带筛选参数)
toListPage() {
var query = {};
var sources = this.value.sources || 'initial';
if (sources === 'category' && this.value.categoryId) {
query.category_id = this.value.categoryId;
} else if (sources === 'tag' && this.value.tagId) {
query.tag_id = this.value.tagId;
}
this.$util.redirectTo('/pages_tool/article/list', query);
},
imgError(index) {
// 图加载失败 → 清空 cover_img 触发 v-if 隐藏图位(无图就隐藏策略)
if (!this.list[index]) return;
this.$set(this.list, index, {
...this.list[index],
cover_img: ''
});
},
// ========== 跑马灯触摸控制(连续/步进模式统一:暂停动画,允许用户拖拽手动滚动)==========
// 用户触摸 → 停止自动滚动,记录拖拽起始位置(先不标记为拖拽,避免点击被误识别)
onMarqueeTouchStart(e) {
this.stopAllMarquee();
this.isUserDragging = false;
this.dragStartY = (e.touches && e.touches[0]) ? e.touches[0].clientY : 0;
this.dragStartTranslateY = this.marqueeMode === 'step' ? this.stepTranslateY : this.continuousTranslateY;
},
// 用户拖拽 → 当移动距离超过阈值后标记为拖拽,并根据手指位移即时改变 translateY
onMarqueeTouchMove(e) {
var y = (e.touches && e.touches[0]) ? e.touches[0].clientY : 0;
var deltaY = y - this.dragStartY;
if (!this.isUserDragging) {
// 5px 阈值:只有真正滑动时才进入手动滚动,避免点击触发
if (Math.abs(deltaY) < 5) return;
this.isUserDragging = true;
}
var newTranslateY = this.dragStartTranslateY - deltaY;
var firstH = this.firstListHeight;
if (firstH > 0) {
// 限制在 [0, firstListHeight) 循环区间内
newTranslateY = ((newTranslateY % firstH) + firstH) % firstH;
}
if (this.marqueeMode === 'step') {
this.stepTranslateY = newTranslateY;
} else {
this.continuousTranslateY = newTranslateY;
}
},
// 用户松手 → 若只是点击则立即恢复,否则 2s 后从当前位置恢复自动滚动
onMarqueeTouchEnd() {
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
if (!this.isUserDragging) {
// 纯点击:立即恢复自动滚动,不影响跳转到详情
this.resumeAutoScroll();
return;
}
this.isUserDragging = false;
var self = this;
this.marqueeResumeTimer = setTimeout(function() {
self.resumeAutoScroll();
}, 2000);
},
// ========== H5 鼠标拖拽支持 ==========
// 背景:H5 环境下 touchstart/touchmove/touchend 不会触发(浏览器鼠标用户用 mouse);
// 因此需要补 mousedown/mousemove/mouseup + wheel 处理。
// 策略:复用 onMarqueeTouchStart/Move/End 的核心逻辑,把 e.clientY 当作手指 Y 坐标处理。
// 关键:① mousemove/mouseup 在 mounted 时挂到 window,避免鼠标移出容器后丢失事件;
// ② 全局禁用 selectstart,防止拖拽时选中文字;
// ③ wheel 事件按 deltaY 反向推动 translateY。
//
// 设计:H5 专属事件全部在 mounted 中通过 addEventListener 绑定,而不是写在模板上,
// 避免小程序/鸿蒙端产生"未知事件"编译警告。所有端共用同一份模板。
bindMarqueeMouseEvents() {
// #ifdef H5
// 防止重复绑定(mounted + list.length watcher 都会调用)
if (this._marqueeEventsBound) return;
// 关键:list.length 变化触发 watcher 时,vue 已把 list 改了但 v-if 还没渲染到 DOM,
// $nextTick 也只保证本组件渲染完毕,但 v-if 子树内的 DOM 还在等父级渲染;
// 经验上需要再等一段时间(测试 1000ms 后能找到,vue 异步 + skeleton 内部)才能 querySelector。
var self = this;
setTimeout(function() {
self._doBindMarqueeMouseEvents();
}, 1000);
// #endif
},
_doBindMarqueeMouseEvents() {
// #ifdef H5
if (this._marqueeEventsBound) return;
var el = this._getMarqueeEl();
if (!el) return;
this._marqueeEl = el;
this._marqueeEventsBound = true;
// 与 diy-image-nav 同款经验:uni-app 的自定义元素(uni-view)在 H5 下
// 也会拦截/重定向 mousedown 到 HTML,导致绑到 .auto-scroll-list 上的监听器收不到。
// 解决:document 级别 capture + 用 clientX/clientY 判断是否落在容器内。
document.addEventListener('mousedown', this.onMarqueeMouseDown, true);
document.addEventListener('wheel', this.onMarqueeWheel, { passive: false, capture: true });
window.addEventListener('mousemove', this.onMarqueeMouseMove);
window.addEventListener('mouseup', this.onMarqueeMouseUp);
// click 拦截:拖拽后屏蔽接下来的 click(防止 uni-app @tap.stop 误触发跳转)
document.addEventListener('click', this.onMarqueeMaybeClick, true);
// 全局禁用 selectstart,防止拖拽时选中文字
this._suppressSelect = function(ev) {
if (ev && ev.preventDefault) ev.preventDefault();
};
document.addEventListener('selectstart', this._suppressSelect);
// #endif
},
unbindMarqueeMouseEvents() {
// #ifdef H5
document.removeEventListener('mousedown', this.onMarqueeMouseDown, true);
document.removeEventListener('wheel', this.onMarqueeWheel, true);
window.removeEventListener('mousemove', this.onMarqueeMouseMove);
window.removeEventListener('mouseup', this.onMarqueeMouseUp);
document.removeEventListener('click', this.onMarqueeMaybeClick, true);
if (this._suppressSelect) {
document.removeEventListener('selectstart', this._suppressSelect);
this._suppressSelect = null;
}
this._marqueeEl = null;
this._marqueeEventsBound = false;
// #endif
},
_getMarqueeEl() {
// #ifdef H5
// .auto-scroll-list 是 v-if 渲染的,Vue $refs 不收集。
// x-skeleton 是 web component,querySelector 不能穿透其 shadow DOM,
// 但 .auto-scroll-list 直接在 document 下可被查找到 — 而本组件只有这一个 .auto-scroll-list,
// 不会与其他组件冲突。
var el = document.querySelector('.auto-scroll-list');
if (el && el.nodeType === 1) return el;
return null;
// #endif
},
onMarqueeMouseDown(e) {
// #ifdef H5
if (!this.useScroll) return;
// document 级别监听,用 bounding box 判断是否落在容器内
if (!this._marqueeEl) return;
var rect = this._marqueeEl.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;
this.stopAllMarquee();
this._mouseDownActive = true; // 标记 mousedown 成功触发,mousemove 用此开关
this._mouseDidDrag = false; // mousedown 时清零,5px 阈值后才置 true
this._suppressNextClick = false;
this.isUserDragging = false; // 5px 阈值后才进入真正拖动状态
this.dragStartY = y;
this.dragStartTranslateY = this.marqueeMode === 'step' ? this.stepTranslateY : this.continuousTranslateY;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
// #endif
},
onMarqueeMouseMove(e) {
// #ifdef H5
// 关键:必须在 mousedown 成功触发后才响应。
// 否则:hover 状态下的鼠标随意移动,deltaY 累积超过 5px 阈值后会误触发滚动。
if (!this._mouseDownActive) return;
var y = (e.clientY != null) ? e.clientY : 0;
var deltaY = y - this.dragStartY;
if (!this.isUserDragging) {
// 5px 阈值:只有真正滑动时才进入手动滚动,避免点击触发
if (Math.abs(deltaY) < 5) return;
this.isUserDragging = true;
this._mouseDidDrag = true;
}
var newTranslateY = this.dragStartTranslateY - deltaY;
var firstH = this.firstListHeight;
if (firstH > 0) {
// 限制在 [0, firstListHeight) 循环区间内
newTranslateY = ((newTranslateY % firstH) + firstH) % firstH;
}
if (this.marqueeMode === 'step') {
this.stepTranslateY = newTranslateY;
} else {
this.continuousTranslateY = newTranslateY;
}
// #endif
},
onMarqueeMouseUp() {
// #ifdef H5
// 解除 selectstart 抑制(mousemove/mouseup 由 unbindMarqueeMouseEvents 统一管理,无需此处解绑)
if (this._suppressSelect) {
document.removeEventListener('selectstart', this._suppressSelect);
this._suppressSelect = null;
}
this._mouseDownActive = false;
// 如果确实拖拽过,标记接下来一次 click 应当被拦截(防止拖拽结束后误触发跳转)
if (this._mouseDidDrag) {
this._suppressNextClick = true;
}
// 复用 touchend 的松手逻辑:纯点击立即恢复,拖拽后 2s 恢复
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
if (!this.isUserDragging) {
this.resumeAutoScroll();
return;
}
this.isUserDragging = false;
var self = this;
this.marqueeResumeTimer = setTimeout(function() {
self.resumeAutoScroll();
}, 2000);
// #endif
},
// click 拦截器:如果 mouseup 设置了 _suppressNextClick,拦截这一次 click 并清除标记。
// 即使 uni-app 把 @tap.stop 编译成 inline onclick,我们的 capture 阶段也能拦截到。
onMarqueeMaybeClick(e) {
// #ifdef H5
if (!this._suppressNextClick) return;
if (!e.target) return;
var el = this._marqueeEl;
if (!el || !el.contains(e.target)) return;
this._suppressNextClick = false;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
// #endif
},
// 鼠标滚轮支持:H5 用户在 PC 浏览器滚动鼠标滚轮时,把 deltaY 转译为 translateY 推动
onMarqueeWheel(e) {
// #ifdef H5
if (!this.useScroll) return;
// document 级别监听,用 bounding box 判断
if (!this._marqueeEl) return;
var rect = this._marqueeEl.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;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
var dy = (e.deltaY != null) ? e.deltaY : 0;
if (dy === 0) return;
// 滚轮向下 → 内容向上滚 → translateY 增加
var newTranslateY;
if (this.marqueeMode === 'step') {
newTranslateY = this.stepTranslateY + dy;
} else {
newTranslateY = this.continuousTranslateY + dy;
}
var firstH = this.firstListHeight;
if (firstH > 0) {
// 限制在 [0, firstListHeight) 循环区间内
newTranslateY = ((newTranslateY % firstH) + firstH) % firstH;
}
if (this.marqueeMode === 'step') {
this.stepTranslateY = newTranslateY;
} else {
this.continuousTranslateY = newTranslateY;
}
// 滚轮滚动时停止自动滚动,模拟手指拖拽行为
this.stopAllMarquee();
// 2s 后从当前位置恢复
var self = this;
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
this.marqueeResumeTimer = setTimeout(function() {
self.resumeAutoScroll();
}, 2000);
// #endif
},
// 从当前位置恢复自动滚动(由 touchend/点击触发)
resumeAutoScroll() {
if (!this.useScroll) return;
if (this.marqueeMode === 'step') {
this.resumeStepMarquee();
} else {
this.startContinuousMarquee(false);
}
},
// 停止所有自动滚动(连续+步进)及恢复定时器
stopAllMarquee() {
this.stopContinuousMarquee();
this.stopStepMarquee();
if (this.marqueeResumeTimer) {
clearTimeout(this.marqueeResumeTimer);
this.marqueeResumeTimer = null;
}
},
// ========== 连续滚动模式:JS 控制 translateY 平滑滚动 ==========
// 启动:根据 marqueeDelay 初始化偏移后开始逐帧滚动
// resetOffset=true 时从头计算初始偏移(首次启动/模式切换),否则从当前 continuousTranslateY 继续
startContinuousMarquee(resetOffset) {
this.stopContinuousMarquee();
if (!this.list || this.list.length <= 1 || this.isUserDragging) return;
var firstH = this.firstListHeight;
if (firstH > 0 && resetOffset) {
// 根据 marqueeDelay 计算初始偏移,与旧 CSS animation-delay 对齐
var initialOffset = (this.marqueeDelay / this.marqueeDuration) * firstH;
this.continuousTranslateY = initialOffset % firstH;
}
this.continuousLastTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
this.continuousRAF = this._requestAnimationFrame(this.continuousFrame);
},
stopContinuousMarquee() {
if (this.continuousRAF) {
this._cancelAnimationFrame(this.continuousRAF);
this.continuousRAF = null;
}
},
continuousFrame() {
if (!this.useScroll || this.isUserDragging || this.marqueeMode !== 'continuous') return;
var now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
var delta = now - this.continuousLastTime;
this.continuousLastTime = now;
var firstH = this.firstListHeight;
if (firstH <= 0) {
this.continuousRAF = this._requestAnimationFrame(this.continuousFrame);
return;
}
// 速度: firstListHeight / marqueeDuration (rpx/s),根据时间增量推进
var speed = firstH / this.marqueeDuration;
this.continuousTranslateY += speed * delta / 1000;
// 无缝循环:滚完第一份 A 后瞬时跳回 0,由于 A+A 内容相同,视觉上无跳变
if (this.continuousTranslateY >= firstH) {
this.continuousTranslateY = 0;
}
this.continuousRAF = this._requestAnimationFrame(this.continuousFrame);
},
// 跨端 requestAnimationFrame 兼容:H5 直接用 window,小程序/App 用 uni;极端情况回退 setTimeout
_requestAnimationFrame(callback) {
if (typeof uni !== 'undefined' && uni.requestAnimationFrame) {
return uni.requestAnimationFrame(callback);
}
// #ifdef H5
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
return window.requestAnimationFrame(callback);
}
// #endif
return setTimeout(callback, 16);
},
_cancelAnimationFrame(id) {
if (typeof uni !== 'undefined' && uni.cancelAnimationFrame) {
return uni.cancelAnimationFrame(id);
}
// #ifdef H5
if (typeof window !== 'undefined' && window.cancelAnimationFrame) {
return window.cancelAnimationFrame(id);
}
// #endif
return clearTimeout(id);
},
// ========== 步进模式:JS 控制 translateY 逐条滚动 ==========
// 启动:从 item 0 开始,先 pause pauseDuration ms 再滚到 item 1
// 注意: 方法名不以 _ 开头,避免 Vue 2 不代理到 this 上
startStepMarquee() {
this.stopStepMarquee();
if (!this.list || this.list.length <= 1 || this.isUserDragging) return;
this.stepIndex = 0;
this.stepTranslateY = 0;
this.isStepSnapping = false;
var self = this;
this.stepTimer = setTimeout(function() {
self.stepAdvance();
}, this.pauseDuration);
},
// 停止:清除所有 setTimeout,避免组件销毁/暂停后仍然推进
stopStepMarquee() {
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
},
// 用户拖拽结束后,从当前位置吸附到最近的 item,然后继续步进
resumeStepMarquee() {
var offsets = this.itemOffsetArr;
var n = offsets.length;
if (n <= 1) return;
var firstH = this.firstListHeight;
var y = this.stepTranslateY;
// 归一化到 [0, firstH)
y = ((y % firstH) + firstH) % firstH;
// 找到最接近的 offset
var nearestIndex = 0;
var minDiff = Infinity;
for (var i = 0; i < n; i++) {
var diff = Math.abs(offsets[i] - y);
if (diff < minDiff) {
minDiff = diff;
nearestIndex = i;
}
}
this.stepIndex = nearestIndex;
this.stepTranslateY = offsets[nearestIndex];
this.isStepSnapping = false;
var self = this;
this.stepTimer = setTimeout(function() {
self.stepAdvance();
}, this.pauseDuration);
},
// 推进到下一条:
// - 普通下一条:动画滚到 itemOffsetArr[i+1],停 pauseDuration ms,再推进
// - 最后一条:先动画滚到「第二份 item 0」= translateY(-firstListHeight),
// 动画结束后瞬时跳回 translateY(0)(无过渡,视觉上无感),进入 pause → 再从 item 1 继续
stepAdvance() {
var offsets = this.itemOffsetArr;
var n = offsets.length;
if (n <= 1) return;
var firstH = this.firstListHeight;
this.stepIndex++;
var self = this;
if (this.stepIndex >= n) {
// 最后一条的下一条:先到第二份的 item 0,再跳回
this.stepIndex = 0;
this.isStepSnapping = false;
this.stepTranslateY = firstH;
// stepAnimDuration 后动画完成,瞬时跳回第一份 item 0
this.stepTimer = setTimeout(function() {
self.isStepSnapping = true;
self.stepTranslateY = 0;
// 等一个 tick 让 transition:none 生效,再进入 pause
self.stepTimer = setTimeout(function() {
self.isStepSnapping = false;
// pauseDuration 后再滚到 item 1
self.stepTimer = setTimeout(function() {
self.stepAdvance();
}, self.pauseDuration);
}, 50);
}, this.stepAnimDuration);
} else {
// 正常推进:滚到 itemOffsetArr[stepIndex]
this.isStepSnapping = false;
this.stepTranslateY = offsets[this.stepIndex];
// 动画完 → pause → 再推进
this.stepTimer = setTimeout(function() {
self.stepTimer = setTimeout(function() {
self.stepAdvance();
}, self.pauseDuration);
}, this.stepAnimDuration);
}
}
}
};
</script>
<style lang="scss">
/* 与 ns-newform 对齐: 在 .article-wrap 上通过内联 style 写入 CSS 变量,
子元素通过 var() 读取。CSS 变量在 H5 / 微信小程序 WXSS / 鸿蒙元服务均支持继承。 */
.article-wrap {
/* CSS 变量默认值兜底(老数据 / 静态预览场景下用以下值;默认值与后端 design.js nestedDefaults 对齐) */
--article-component-bg-color: transparent;
--article-element-bg-color: transparent;
--article-top-around-radius: 0rpx;
--article-bottom-around-radius: 0rpx;
--article-top-element-around-radius: 0rpx;
--article-bottom-element-around-radius: 0rpx;
--article-tag-bg-color: #f0f9ff;
--article-tag-text-color: #1e6bb8;
--article-tag-border-color: #b3d8ff;
--article-tag-font-size: 22rpx;
--article-abstract-color: #888888;
--article-abstract-font-size: 24rpx;
--article-meta-color: #999ca7;
--article-meta-icon-size: 24rpx;
--article-meta-font-size: 24rpx;
--article-view-more-font-size: 26rpx;
--article-view-more-icon-size: 24rpx;
--article-item-gap: 16rpx;
--article-divider-color: #eeeeee;
--article-title-fs-1: 30rpx;
--article-title-fs-2: 30rpx;
--article-title-fs-3: 24rpx;
--article-title-fs-4: 28rpx;
/* 根容器圆角:由 --article-top/bottom-around-radius 驱动 */
border-top-left-radius: var(--article-top-around-radius, 0);
border-top-right-radius: var(--article-top-around-radius, 0);
border-bottom-left-radius: var(--article-bottom-around-radius, 0);
border-bottom-right-radius: var(--article-bottom-around-radius, 0);
.list-wrap {
// ============ 自动垂直滚动(CSS 跑马灯实现) ============
// 设计目标:item 紧凑(自然高度) + 间距恒定(itemGap) + 持续向上滚动,循环不跳变
// 外层 .auto-scroll-list 固定高度(= _visibleWindowHeight,即窗口可见 N 行),overflow:hidden;
// 内层 .marquee-track 放置 loopedList(A+A 两份),
// @keyframes marquee-up 把 translateY 从 0 → -第一份高度,linear + infinite 循环;
// 第一份末行滚出窗口后,第二份首行刚好顶替,视觉上无缝接续。
// 动画时长 = 第一份高度 / 速度(默认 ≈ 20rpx/秒 ≈ 10px/秒,慢速平稳,app 阅读友好);
// 通过 swiperConfig.marqueeDuration(秒,不传走默认)可手动覆盖。
// 触摸暂停:touchstart 加 paused class,animation-play-state=paused;
// touchend 2s 后移除 paused class,继续滚动。
.auto-scroll-list {
width: 100%;
overflow: hidden;
position: relative;
// ========== H5 鼠标拖拽支持 ==========
// 禁用鼠标选中文字(避免拖拽时高亮一片文字),并保留 cursor 提示可拖拽。
// #ifdef H5
user-select: none;
-webkit-user-select: none;
cursor: grab;
&:active {
cursor: grabbing;
}
// #endif
.marquee-track {
will-change: transform;
}
// 关键:loopedList 把 list 复制了一份,最后一条 .item(2N-1)也会被 :last-of-type 命中,
// 导致其 margin-bottom: 0,而前面所有 item 是 itemGap。
// 这会破坏无缝循环:滚到 A 末行与 A+A 中第二份首行拼接处时,突然多出一截空白,视觉闪一下。
// 修复:scroll-view 内所有 item 一视同仁,最后一条也保持 itemGap。
.item {
&:last-of-type {
margin-bottom: var(--article-item-gap, 16rpx);
}
}
}
.item {
margin-bottom: var(--article-item-gap, 16rpx);
/* 背景 / 圆角通过 CSS 变量从 .article-wrap 继承(由 warpWrapStyle 注入) */
background-color: var(--article-element-bg-color, transparent);
border-top-left-radius: var(--article-top-element-around-radius, 0);
border-top-right-radius: var(--article-top-element-around-radius, 0);
border-bottom-left-radius: var(--article-bottom-element-around-radius, 0);
border-bottom-right-radius: var(--article-bottom-element-around-radius, 0);
// 静态列表(非滚动模式)最后一条不补间距
&:last-of-type {
margin-bottom: 0;
}
}
.style-1 .item {
padding: 20rpx;
}
// 摘要(.desc)行高兜底:必须显式 1.4,防止被 main.scss 全局 view { line-height: 1.8 } 撑高。
// style-1/2 的 .desc 已显式声明,此规则覆盖 style-3/4 等未单独定义 .desc 的风格。
// 重要性:与 _itemHeight 公式保持一致(摘要行高 = 1.4),否则有摘要 item 实际高度超出 maxItemHeight,
// 撑破 min-height 布局,导致有/无摘要的 item 视觉高度不一 → 间距看起来不一致。
.desc {
line-height: 1.4;
}
.tag-list {
margin-top: 10rpx;
line-height: 1;
.tag {
display: inline-block;
/* 3 色由 CSS 变量驱动(来自 warpWrapStyle 注入;老数据走默认值) */
background: var(--article-tag-bg-color, #f0f9ff);
color: var(--article-tag-text-color, #1e6bb8);
border: 2rpx solid var(--article-tag-border-color, #b3d8ff);
border-radius: 20rpx;
padding: 2rpx 14rpx;
margin: 0 8rpx 8rpx 0;
/* 标签字号由 CSS 变量驱动 */
font-size: var(--article-tag-font-size, 22rpx);
}
}
.read-wrap {
display: flex;
/* 文字色由 CSS 变量驱动(来自 warpWrapStyle 注入;老数据走默认值) */
color: var(--article-meta-color, #999ca7);
align-items: center;
margin-top: 10rpx;
line-height: 1;
flex-wrap: wrap;
/* 阅读区文字大小由 CSS 变量驱动(覆盖原 SCSS 变量 $font-size-tag) */
font-size: var(--article-meta-font-size, 24rpx);
.category-icon {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $base-color;
margin-right: 10rpx;
}
.category-name {
margin-right: 10rpx;
}
.num {
/* 显式继承 .read-wrap 的 color / font-size:避免 main.scss 里
view { color: $color-title; font-size: $font-size-base; } 的全局规则截胡
(.num 是 <view> 但不是 .read-wrap,特异性 view < .read-wrap 不再生效),
导致阅读数 / 点赞数 与 category-name / date 颜色和字号不一致 */
color: inherit;
font-size: inherit;
display: inline-flex;
align-items: center;
margin-left: 16rpx;
.iconfont {
/* 图标大小由 CSS 变量驱动 */
font-size: var(--article-meta-icon-size, 24rpx);
/* 兼容老的 iconfont 写法(图与数字间距) */
margin-right: 10rpx;
}
/* 图与数字之间的视觉间隔。
之前用 .uni-icons { margin-right } 仅 H5 生效:
.uni-icons 是 <uni-icons> 组件内部 <text> 的 class,
微信小程序 / 鸿蒙元服务默认 styleIsolation=isolated,
父级选择器穿透不进组件 shadow root,样式不生效。
改成给数字 <text> 加 margin-left:
- text 是 .num 的直接子元素,与 .num 同组件,不受隔离影响
- H5 / 微信小程序 / 鸿蒙 三个环境均生效 */
text {
margin-left: 10rpx;
}
}
.date {
/* margin-left: auto 把日期推到阅读区最右侧(无论是否折行),
与左侧的「分类 / 阅读数 / 点赞数」自然形成「左 meta + 右日期」布局。
之前用 16rpx 让日期紧跟左侧,布局上「左 meta + 右日期」不成立。*/
margin-left: auto;
}
}
.view-more {
text-align: center;
padding: 20rpx 0;
/* 字号由 CSS 变量驱动;颜色由 viewMoreStyle 注入(支持点击重置) */
font-size: var(--article-view-more-font-size, 26rpx);
.uni-icons {
/* 图标大小由 CSS 变量驱动 */
font-size: var(--article-view-more-icon-size, 24rpx);
margin-left: 8rpx;
vertical-align: middle;
}
}
// ========== style-1: 横向卡片(图左文右) ==========
// 第 14 轮诉求:有图/无图两种 item 的「分类 / 阅读数 / 点赞数 / 日期」一行垂直对齐
// 关键:不能破坏原先有图 item 的布局 — 保留 .info-wrap 的 justify-content: space-between
// (title 在顶、read-wrap 在底,这是有图 item 的经典样式)。
// 无图 item 之前没设 space-between,所以 read-wrap 默认紧跟 title/desc/tag 下方,Y 不齐。
// 修复:无图 item 单独加 space-between + 给 .info-wrap 一个与有图场景对齐的 min-height,
// 让无图的 read-wrap 也被推到 info-wrap 底部。有图 item 一切照旧,不破坏。
&.style-1 {
.item {
display: flex;
padding: 20rpx;
.article-img {
margin-right: 20rpx;
width: 160rpx;
height: 160rpx;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.cover-img {
width: 100%;
height: 100%;
}
}
.info-wrap {
flex: 1;
display: flex;
flex-direction: column;
/* 保留原布局:title 顶部、read-wrap 底部(经典 style-1 横向卡片样式) */
justify-content: space-between;
overflow: hidden;
.title {
font-weight: bold;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
/* 标题字号由 CSS 变量驱动(每风格独立) */
font-size: var(--article-title-fs-1, 30rpx);
line-height: 1.4;
}
.desc {
/* 颜色由 CSS 变量驱动(来自 warpWrapStyle 注入;老数据走默认值) */
color: var(--article-abstract-color, #888888);
/* 摘要字号由 CSS 变量驱动 */
font-size: var(--article-abstract-font-size, 24rpx);
/* line-height 必须显式声明:全局 main.scss 的 view { line-height: 1.8 } 会让 .desc 撑高,
与 _itemHeight 公式用的 1.4 不一致,导致有摘要 item 实际高度超过 min-height(maxItemHeight),
而无摘要 item 被 min-height 撑高,两者视觉高度不一致 → 视觉间距不一致。
显式 1.4 与 .title 对齐,确保实际渲染 = 估算值。*/
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
}
// ========== 无图 item:让 read-wrap 也走 space-between 锚到 info-wrap 底部 ==========
// 有图 item 保留原样(title 顶 / read-wrap 底,经典样式);
// 无图 item 之前 read-wrap 紧跟内容下方,现在用 space-between + 合适的 min-height
// 让 read-wrap 也钉到 info-wrap 底部,与有图 item 的 read-wrap 行 Y 坐标对齐。
//
// min-height 取值 160rpx = 与 image 高度一致。
// 有图 item 的 info-wrap 实际高度 = max(image 160, content) = 至少 160(短内容)或更高(长内容);
// 短内容时 image 160 撑住 .item 高度,read-wrap 用 space-between 推到 160 底部。
// 无图 item 的 info-wrap 设 min-height: 160rpx,即使内容更短,info-wrap 仍 ≥ 160,
// space-between 把 read-wrap 推到 160 底部 → 与有图短内容场景 read-wrap Y 一致。
// 长内容时(标题 2 行 + 摘要 2 行 + 标签),info-wrap 实际 > 160,两种 item 各自撑高,
// space-between 都把 read-wrap 推到 info-wrap 底部 → Y 仍一致(都贴在各自 item 底部)。
.item.no-image {
.info-wrap {
/* 关键:让无图 info-wrap 至少 160rpx,匹配有图短内容场景 */
min-height: 160rpx;
/* 保留 tag-list margin-top: 0 的旧规则(避免与有图节奏不同),
但不要「read-wrap margin-top: 0」 — 让 space-between 决定 read-wrap 位置 */
.tag-list {
margin-top: 0;
}
}
}
}
// ========== style-2: 大图卡片(上图下文) ==========
&.style-2 {
.item {
padding: 0;
overflow: hidden;
.article-img {
width: 100%;
height: 320rpx;
overflow: hidden;
.cover-img {
width: 100%;
height: 100%;
}
}
.info-wrap {
padding: 20rpx;
.title {
font-weight: bold;
/* 标题字号由 CSS 变量驱动(每风格独立) */
font-size: var(--article-title-fs-2, 30rpx);
line-height: 1.4;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.desc {
/* 颜色由 CSS 变量驱动(来自 warpWrapStyle 注入;老数据走默认值) */
color: var(--article-abstract-color, #888888);
/* 摘要字号由 CSS 变量驱动 */
font-size: var(--article-abstract-font-size, 24rpx);
/* line-height 必须显式 1.4:与 .title 对齐,避免被 main.scss 全局 view { line-height: 1.8 } 撑高,
导致有摘要 item 实际高度超过 _itemHeight 估算值,撑破 min-height 布局,视觉间距不一致。*/
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 10rpx;
}
}
}
}
// ========== style-3: 三列网格 ==========
&.style-3 {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
&::after {
content: '';
flex: auto;
}
.item {
width: 32%;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
.article-img {
width: 100%;
height: 200rpx;
overflow: hidden;
.cover-img {
width: 100%;
height: 100%;
}
}
.info-wrap {
padding: 10rpx 14rpx;
.title {
/* 标题字号由 CSS 变量驱动(每风格独立) */
font-size: var(--article-title-fs-3, 24rpx);
font-weight: bold;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.read-wrap {
.category-icon,
.category-name {
display: none;
}
// style-3 不再硬覆写字号,跟随 --article-meta-font-size(用户配多小就多小)
}
}
}
}
// ========== style-4: 纯文字列表(无封面) ==========
&.style-4 {
.item {
padding: 20rpx;
margin-bottom: 0;
/* 分割线颜色由 CSS 变量驱动 */
border-bottom: 2rpx dashed var(--article-divider-color, #eeeeee);
&:last-of-type {
border-bottom: 0;
}
.info-wrap {
.title {
/* 标题字号由 CSS 变量驱动(每风格独立) */
font-size: var(--article-title-fs-4, 28rpx);
font-weight: bold;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
// 空数据占位
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
.empty-text {
color: #999;
font-size: 26rpx;
}
}
}
}
</style>