refactor(article-component): 替换swiper为scroll-view实现自动滚动,优化紧凑布局

1.  将原swiper自动滚动方案替换为scroll-view实现,支持item自然高度,不再强制等高,解决短item下方留白问题
2.  新增连贯滚动和步进滚动两种自动滚动模式,适配不同场景需求
3.  修复H5端rpx单位不兼容问题,统一内联样式单位转换逻辑
4.  优化滚动容器高度计算,根据配置显示数量动态计算窗口高度
5.  新增触摸暂停恢复逻辑,提升用户交互体验
This commit is contained in:
2026-06-15 14:35:17 +08:00
parent 089da9181b
commit de4b510c00

View File

@@ -3,53 +3,58 @@
<view class="article-wrap" :style="warpWrapStyle">
<!-- 注: :class 用内联数组,与 ns-newform 对齐;computed listClass/itemClass 已移除(避免数组 toString 用逗号 join 的问题) -->
<view :class="['list-wrap', value.style || 'style-1']">
<!-- 自动垂直滚动(默认开启,兼容老数据:无 scrollMode/swiperConfig 字段也走这里) -->
<swiper v-if="list.length > 0 && useScroll" class="auto-scroll-swiper"
:style="swiperWrapStyle"
:vertical="swiperConfig.vertical !== false"
:autoplay="swiperConfig.autoplay !== false"
:circular="swiperConfig.circular !== false"
:interval="swiperConfig.interval || 3000"
:duration="swiperConfig.duration || 500"
:display-multiple-items="safeDisplayMultipleItems">
<swiper-item v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)">
<view class="swiper-item-content">
<view :class="['item', value.ornament && value.ornament.type, !item.cover_img && value.style !== 'style-4' ? 'no-image' : '']" :style="itemStyle">
<!-- 封面图:无图时隐藏图位;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)" show-menu-by-longpress></image>
<!-- 自动垂直滚动(scroll-view 实现,默认开启,兼容老数据:无 scrollMode/swiperConfig 字段也走这里)
设计:item 自然高度 + 固定 itemGap,scrollTop 累加 + loopedList 复制实现无缝循环
vs 旧 swiper 方案:旧方案用 min-height 强制等高 → 短 item 下方空一大块,不符合"紧凑"诉求
新方案:item 完全用内容撑起,每条 item 后固定 itemGap 空白,视觉间距天然恒等 -->
<scroll-view v-if="list.length > 0 && useScroll" class="auto-scroll-list"
:style="autoScrollListStyle"
scroll-y="true"
:scroll-top="scrollTop"
:scroll-with-animation="scrollWithAnimation"
:show-scrollbar="false"
:enhanced="false"
:bounces="false"
:throttle="false"
@touchstart="onAutoScrollTouchStart"
@touchend="onAutoScrollTouchEnd">
<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>{{ item.read_num || 0 }}</text>
</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>{{ item.read_num || 0 }}</text>
</view>
<view class="num" v-if="dianzanStyle.support && dianzanStyle.control">
<uni-icons type="star" size="12" :color="metaColor" />
<text>{{ item.dianzan_num || 0 }}</text>
</view>
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
</view>
<view class="num" v-if="dianzanStyle.support && dianzanStyle.control">
<uni-icons type="star" size="12" :color="metaColor" />
<text>{{ item.dianzan_num || 0 }}</text>
</view>
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
</view>
</view>
</swiper-item>
</swiper>
</view>
</scroll-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"
@@ -123,16 +128,46 @@ export default {
headWidth: '160rpx',
headHeight: '160rpx',
textRows: 2
}
},
// ========== 滚动模式(scroll-view 自动滚动)状态 ==========
// scrollTop 累加驱动滚动;loopedList 把 list 复制一份实现无缝循环
scrollTop: 0,
autoScrollTimer: null, // setTimeout(16ms) id(60fps tick,跨端平滑,替代 setInterval 30ms)
autoScrollResumeTimer: null, // touchend 后延迟恢复
_scrollAccumulator: 0, // sub-pixel 累计,每帧 +0.4,到 ≥1 才提交 scrollTop
// ========== 步进模式(scroll-step + pause)状态 ==========
// 滚动一下 + 停留一会:每次把 scrollTop 推到下一个 item 起点,停 pauseDuration ms,再推下一个
// 跳回 0(列表末尾 → 列表开头)时也走 stopPause + 立即 0,视觉无感
_stepCurrentIndex: 0, // 当前已显示到的 item 索引(0..N-1)
_stepPhase: 'idle', // 'idle' | 'scrolling' | 'pausing'
_stepResetting: false // true=正在跳回 0,scrollWithAnimation computed 翻成 false,本次 scrollTop=0 赋值无动画
};
},
created() {
this.getList();
},
mounted() {
// 自动滚动由 watch 'list.length' 在 list 加载完后启动(mounted 时 list 仍为空)
},
beforeDestroy() {
this.stopAutoScroll();
if (this.autoScrollResumeTimer) {
clearTimeout(this.autoScrollResumeTimer);
this.autoScrollResumeTimer = null;
}
},
watch: {
// 组件刷新监听
componentRefresh() {
this.getList();
},
// list 加载完后启动自动滚动(mounted 时 list 还是空,需监听 list 长度变化)
'list.length'(newLen) {
if (newLen > 1 && this.useScroll) {
this.$nextTick(() => {
this.startAutoScroll();
});
}
}
},
computed: {
@@ -167,39 +202,45 @@ export default {
// CSS 变量(向下传递给所有子元素,通过 var() 读取)
'--article-component-bg-color': v.componentBgColor || 'transparent',
'--article-element-bg-color': v.elementBgColor || 'transparent',
'--article-top-around-radius': isRootRound ? `${r(v.topAroundRadius, 0) * 2}rpx` : '0rpx',
'--article-bottom-around-radius': isRootRound ? `${r(v.bottomAroundRadius, 0) * 2}rpx` : '0rpx',
'--article-top-element-around-radius': isItemRound ? `${r(v.topElementAroundRadius, 0) * 2}rpx` : '0rpx',
'--article-bottom-element-around-radius': isItemRound ? `${r(v.bottomElementAroundRadius, 0) * 2}rpx` : '0rpx',
// ========== 关键:内联 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': r(tag.fontSize, 22) + 'rpx',
'--article-tag-font-size': this._rpx(r(tag.fontSize, 22)),
// 摘要文字色 + 字号(rpx)
'--article-abstract-color': abs.textColor || '#888888',
'--article-abstract-font-size': r(abs.fontSize, 24) + 'rpx',
'--article-abstract-font-size': this._rpx(r(abs.fontSize, 24)),
// 阅读区文字色 + 图标大小 + 文字大小(rpx)
'--article-meta-color': rw.color || '#999ca7',
'--article-meta-icon-size': r(rw.iconSize, 24) + 'rpx',
'--article-meta-font-size': r(rw.fontSize, 24) + 'rpx',
'--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': r(vm.fontSize, 26) + 'rpx',
'--article-view-more-icon-size': r(vm.iconSize, 24) + 'rpx',
// 列表项间距(rpx)
'--article-item-gap': r(v.itemGap, 16) + 'rpx',
'--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 公式一致
'--article-swiper-item-min-height': this.maxItemHeight + 'rpx',
// 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': tfs1 + 'rpx',
'--article-title-fs-2': tfs2 + 'rpx',
'--article-title-fs-3': tfs3 + 'rpx',
'--article-title-fs-4': tfs4 + '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)
@@ -211,19 +252,42 @@ export default {
'background-color': v.elementBgColor || 'transparent'
};
if (ornament.type === 'shadow') {
style['box-shadow'] = `0 0 10rpx ${ornament.color || 'rgba(0, 0, 0, 0.1)'}`;
// 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'] = `2rpx solid ${ornament.color || 'transparent'}`;
style['border'] = `${this._rpx(2)} solid ${ornament.color || 'transparent'}`;
}
return this.$util.objToStyle(style);
},
// swiper 容器高度(由 swiperHeight 计算属性驱动)
swiperWrapStyle() {
// 滚动容器高度(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.swiperHeight}rpx`
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({
@@ -273,12 +337,39 @@ export default {
useScroll() {
return this.scrollMode !== 'none' && this.list.length > 1;
},
// scroll-view 的 scroll-with-animation 绑定
// 连贯模式: false(每帧 scrollTop += 1 同步生效,避免被 200ms 缓动打断造成频闪)
// 步进模式:
// - 推进中(scrolling 阶段,推 scrollTop 到下一个 item 起点): true(走 300-500ms 缓动,视觉是「滚一下」)
// - 跳回 0 那一瞬(从 list 末尾 → 第二份 item 0 / 直接跳到 0): false(无动画,避免「向前滚回」)
// - 暂停中(pausing 阶段): 当前 scrollTop 不动,binding 值无影响
// 实现:_stepResetting 标志位标识「正在跳回 0,本次 scrollTop 赋值不要动画」
scrollWithAnimation() {
if (!this.stepPauseEnabled) return false;
// 步进模式:正在跳回 0 时返回 false,否则 true
return !this._stepResetting;
},
// 安全的 display-multiple-items(确保不大于实际 list 长度,避免 swiper 报错)
safeDisplayMultipleItems() {
var displayCount = this.swiperConfig.displayMultipleItems || 3;
var actualCount = this.list.length;
return actualCount > 0 ? Math.min(displayCount, actualCount) : 1;
},
// 是否启用「滚动一下 + 停留一会」步进模式
// 默认 false(走 sub-pixel 连贯滚动);true 时按 item 粒度滚动,每滚一个 item 停 pauseDuration ms 再滚下一个
stepPauseEnabled() {
return !!this.swiperConfig.stepPause;
},
// 步进模式停留时长(ms),默认 1500
pauseDuration() {
var d = parseInt(this.swiperConfig.pauseDuration);
return d > 0 ? d : 1500;
},
// 步进模式单次滚动动画时长(ms),默认 500(uni-app scroll-view 默认动画 300ms,留 buffer)
stepAnimDuration() {
var d = parseInt(this.swiperConfig.stepAnimDuration);
return d > 0 ? d : 500;
},
// swiper 高度(rpx)优先级:
// 1. 老/新数据显式设置了 swiperConfig.swiperHeight(>0) → 配置值(用户强制覆盖)
// 2. 否则按 (maxItemHeight + itemGap) × N 计算(精确,不加分摊 buffer),其中:
@@ -310,6 +401,37 @@ export default {
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),即「第一份末尾之后接续」
_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)
@@ -321,6 +443,17 @@ export default {
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
@@ -474,6 +607,156 @@ export default {
...this.list[index],
cover_img: ''
});
},
// ========== scroll-view 自动滚动控制(两种模式互不干扰) ==========
// 模式 1: 连贯滚动(sub-pixel + 16ms tick) — 默认
// 设计目标:消除微信小程序「频闪」 + 跨端统一平滑滚动
// 旧方案(频闪根因):setInterval(30ms) + scrollTop += 1 + scroll-with-animation="true"
// - 30ms ≈ 33fps,与屏幕 60fps 不同步,出现周期性跳帧
// - scroll-with-animation 让每次 scrollTop 变化都跑 200ms 缓动,30ms 内动画未完成又被打断
// - MP-WXSS scroll-view 渲染管线压力大,叠加动画 → 肉眼可见 1 像素级跳变
// 新方案:
// - setTimeout(16ms) tick(60fps):跨端一致(MP 无全局 rAF,setTimeout 跨端可用)
// - 每帧累加 0.4px(sub-pixel 抗锯齿),到 ≥ 1 才提交 scrollTop(避免每帧触发 vue 响应式)
// - scroll-with-animation="false":scrollTop 同步生效,不启动 200ms 缓动
// - 跳回 0 用 scrollTop 强制赋值,无动画 → 视觉无感
// - 速度: 0.4 × 60 = 24 px/秒,慢速平稳
//
// 模式 2: 步进滚动(滚一下 + 停留一会) — 后端 stepPause=true 时启用
// 语义:每滚一个 item 停 pauseDuration ms 再滚下一个,循环接续
// 状态机: idle → scrolling(scrollTop 推到下一个 item 起点,scroll-with-animation=true) →
// pausing(setTimeout(pauseDuration)) → scrolling(下一个) → ...
// 跳回 0(末尾 → 开头):scrolling phase 立即把 scrollTop 设为 0(scroll-with-animation 关),视觉无感
// 步进模式用 scroll-with-animation=true 是关键:每次推 scrollTop 是「滚一下」动画,不是直接跳
//
// 启动/暂停/恢复(两种模式共用):
// - 启动:startAutoScroll 根据 stepPauseEnabled 分发
// - 暂停:触摸时 stopAutoScroll,清除 resume timer
// - 恢复:触摸结束 2s 后再 startAutoScroll
// - 销毁:beforeDestroy 清 setTimeout
_scrollTick() {
// 模式 1 连贯滚动 tick
var STEP = 0.4;
this._scrollAccumulator += STEP;
if (this._scrollAccumulator >= 1) {
var inc = Math.floor(this._scrollAccumulator);
this._scrollAccumulator -= inc;
this.scrollTop += inc;
// 滚到第一份底部,跳回 0
if (this.firstListHeight > 0 && this.scrollTop >= this.firstListHeight) {
this.scrollTop = 0;
}
}
// 下一帧
var self = this;
this.autoScrollTimer = setTimeout(function() {
self._scrollTick();
}, 16);
},
// 模式 2 步进滚动:推 scrollTop 到下一个 item 起点,停 pauseDuration,再推下一个,循环接续
// 循环处理:list 末尾 → 跳回 0
// 翻 _stepResetting = true → scrollWithAnimation computed 翻成 false →
// $nextTick 等 scroll-view 接受新 prop → scrollTop = 0(无动画)→ 50ms 后翻回 → 继续推下一个
// 状态机: idle → scrolling(scrollTop 推到下一个 item 起点,scroll-with-animation=true 走缓动) →
// pausing(setTimeout(pauseDuration)) → scrolling(下一个) → ...
_stepAdvance() {
var offsets = this._itemOffsetArr;
if (offsets.length === 0) {
this._stepPhase = 'idle';
return;
}
var n = offsets.length;
var nextIndex = this._stepCurrentIndex + 1;
var isLoop = false;
if (nextIndex >= n) {
// 末尾 → 跳回开头(无动画,scrollTop 设为 0)
nextIndex = 0;
isLoop = true;
}
this._stepCurrentIndex = nextIndex;
this._stepPhase = 'scrolling';
var self = this;
if (isLoop) {
// 翻 _stepResetting 让 scroll-with-animation=false,scrollTop 设为 0
this._stepResetting = true;
this.$nextTick(function() {
self.scrollTop = 0;
// 等 50ms 让 scroll-view 接受新位置,再翻回
self.autoScrollTimer = setTimeout(function() {
self._stepResetting = false;
self._stepPhase = 'scrolling';
self._stepAdvance();
}, 50);
});
return;
}
// 正常推进:scrollTop 推到下一个 item 起点(scroll-with-animation=true 走 300-500ms 缓动)
this.scrollTop = offsets[nextIndex];
// 等动画结束 → 进入 pausing
this.autoScrollTimer = setTimeout(function() {
self._stepPhase = 'pausing';
self.autoScrollTimer = setTimeout(function() {
self._stepPhase = 'scrolling';
self._stepAdvance();
}, self.pauseDuration);
}, this.stepAnimDuration);
},
startAutoScroll() {
if (this.autoScrollTimer) return;
if (!this.list || this.list.length <= 1) return;
if (this.stepPauseEnabled) {
// 模式 2 步进:从 item 0 开始,首次立刻滚到 item 1(scroll-with-animation=true 走缓动)
this._stepCurrentIndex = 0;
this._stepPhase = 'scrolling';
this.scrollTop = 0;
var self = this;
// 首次立即推进(不预先 pause,符合「滚一下 + 停留一会」语义)
this.autoScrollTimer = setTimeout(function() {
self._stepAdvance();
}, 16);
} else {
// 模式 1 连贯
this._scrollAccumulator = 0;
var self = this;
this.autoScrollTimer = setTimeout(function() {
self._scrollTick();
}, 16);
}
},
stopAutoScroll() {
if (this.autoScrollTimer) {
clearTimeout(this.autoScrollTimer);
this.autoScrollTimer = null;
}
this._stepPhase = 'idle';
},
// 用户触摸 → 暂停自动滚动(touchend 2s 后由 onTouchEnd 恢复)
onAutoScrollTouchStart() {
this.stopAutoScroll();
if (this.autoScrollResumeTimer) {
clearTimeout(this.autoScrollResumeTimer);
this.autoScrollResumeTimer = null;
}
},
// 用户松手 → 2s 后恢复自动滚动(给用户阅读时间)
// 步进模式:不重置 _stepCurrentIndex,从用户松手时所在 item 继续滚下一个
// 连贯模式:正常重启 _scrollTick
onAutoScrollTouchEnd() {
if (this.autoScrollResumeTimer) clearTimeout(this.autoScrollResumeTimer);
var self = this;
this.autoScrollResumeTimer = setTimeout(function() {
if (self.stepPauseEnabled) {
// 步进模式:保留 _stepCurrentIndex,从 pausing 阶段继续
self._stepPhase = 'pausing';
self.autoScrollTimer = setTimeout(function() {
self._stepPhase = 'scrolling';
self._stepAdvance();
}, self.pauseDuration);
} else {
// 连贯模式:正常启动
self.startAutoScroll();
}
}, 2000);
}
}
};
@@ -515,37 +798,25 @@ export default {
border-bottom-right-radius: var(--article-bottom-around-radius, 0);
.list-wrap {
// 垂直滚动 swiper
.auto-scroll-swiper {
// ============ 自动垂直滚动(scroll-view 实现) ============
// 设计目标:item 紧凑(自然高度) + 间距恒定(itemGap)
// 实现方式:item 走基础 .item 规则的 margin-bottom: var(--article-item-gap),
// 不再强制 min-height 让 item 用内容撑起,短 item 不会被拉高产生空白。
// scrollTop 累加 + loopedList 复制一份实现无缝循环滚动。
// vs 旧 swiper 方案:旧方案用 min-height 强制等高 + swiper-item-content padding-bottom 制造间距,
// 导致短 item 下方空一大块(违反"紧凑"诉求);新方案天然紧凑。
.auto-scroll-list {
width: 100%;
// swiper-item-content:padding-bottom = itemGap 是制造 item 间恒定视觉间距的关键
// 不管 item 自然高度多少,两条 item 之间的空白永远是 itemGap,
// 而非「perRow - itemHeight」(后者随 item 高度变,间距不可控)。
// 顶部不补 padding,避免与 item padding 叠加导致裁切。
// 兜底:overflow:hidden 处理内容意外超过 maxItemHeight 的边界场景(防破坏布局)
.swiper-item-content {
padding-top: 0;
padding-right: 0;
padding-bottom: var(--article-item-gap, 16rpx);
padding-left: 0;
box-sizing: border-box;
overflow: hidden;
}
// swiper 内 item:
// 1. margin-bottom: 0 — 间距由 .swiper-item-content 的 padding-bottom 承担,避免双重 margin
// 2. min-height = maxItemHeight(动态 CSS 变量)— 强制所有 item 视觉高度统一,短 item 自然撑高
// 3. align-items: flex-start — 覆盖默认 stretch,避免 info-wrap 被拉伸导致短内容被 space-between 拉开
// 关键:loopedList 把 list 复制了一份,最后一条 .item(2N-1)也会被 :last-of-type 命中,
// 导致其 margin-bottom: 0,而前面所有 item 是 itemGap。
// 这会破坏无缝循环:scrollTop 跳回 0 时,用户看到「item 2N-1(无下间距)→ item 0(itemGap)」
// 突然多出一截空白,视觉闪一下。
// 修复:scroll-view 内所有 item 一视同仁,最后一条也保持 itemGap。
.item {
margin-bottom: 0 !important;
min-height: var(--article-swiper-item-min-height, 200rpx);
align-items: flex-start;
}
// style-1 有图时,图片在 min-height item 内垂直居中(避免贴顶导致下方空一大块)
.item .article-img {
align-self: center;
&:last-of-type {
margin-bottom: var(--article-item-gap, 16rpx);
}
}
}
@@ -558,6 +829,7 @@ export default {
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;
}