refactor(diy-article): 替换scroll-view滚动为CSS跑马灯实现

移除原scroll-view自动滚动逻辑,改用CSS animation实现无缝跑马灯效果,解决跨端滚动跳变问题,移除JS定时器驱动逻辑,提升动画流畅度和跨端一致性,优化触摸暂停恢复逻辑。
This commit is contained in:
2026-06-15 15:46:05 +08:00
parent db1d47f456
commit d01b0d46e4

View File

@@ -3,58 +3,56 @@
<view class="article-wrap" :style="warpWrapStyle">
<!-- 注: :class 用内联数组,与 ns-newform 对齐;computed listClass/itemClass 已移除(避免数组 toString 用逗号 join 的问题) -->
<view :class="['list-wrap', value.style || 'style-1']">
<!-- 自动垂直滚动(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"
<!-- 自动垂直滚动(CSS animation 跑马灯实现,默认开启)
设计:loopedList 把 list 复制成 A+A 两份;外层容器固定高度(窗口)overflow:hidden,
内层容器用 @keyframes translateY 从 0 → -第一份高度,动画循环无缝接续。
优势:① 无 JS setInterval 驱动 scrollTop,避免 uni-app scroll-view 在 H5/MP 下单位不一致
导致"滚到底后跳变"问题;② 浏览器原生动画,60fps 平滑,跨端一致;
③ 触摸(touchstart)时 animation-play-state=paused,touchend 2s 后恢复。 -->
<view v-if="list.length > 0 && useScroll"
:class="['auto-scroll-list', { 'is-marquee-paused': marqueePaused }]"
: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>
@touchstart="onMarqueeTouchStart"
@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="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 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="num" v-if="dianzanStyle.support && dianzanStyle.control">
<uni-icons type="star" size="12" :color="metaColor" />
<text>{{ _humanizeNum(item.dianzan_num || 0) }}</text>
<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>
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
</view>
</view>
</view>
</scroll-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"
@@ -129,45 +127,26 @@ export default {
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 赋值无动画
// ========== 跑马灯状态(CSS animation 驱动,替代 scroll-view + scrollTop)==========
// marqueePaused: true 时给外层加 is-marquee-paused class → animation-play-state: paused
// marqueeResumeTimer: touchend 后延迟恢复
marqueePaused: false,
marqueeResumeTimer: null
};
},
created() {
this.getList();
},
mounted() {
// 自动滚动由 watch 'list.length' 在 list 加载完后启动(mounted 时 list 仍为空)
},
beforeDestroy() {
this.stopAutoScroll();
if (this.autoScrollResumeTimer) {
clearTimeout(this.autoScrollResumeTimer);
this.autoScrollResumeTimer = null;
if (this.marqueeResumeTimer) {
clearTimeout(this.marqueeResumeTimer);
this.marqueeResumeTimer = null;
}
},
watch: {
// 组件刷新监听
componentRefresh() {
this.getList();
},
// list 加载完后启动自动滚动(mounted 时 list 还是空,需监听 list 长度变化)
'list.length'(newLen) {
if (newLen > 1 && this.useScroll) {
this.$nextTick(() => {
this.startAutoScroll();
});
}
}
},
computed: {
@@ -324,9 +303,8 @@ export default {
return Object.assign({ color: '#999ca7', iconSize: 24, fontSize: 24 },
this.value.readWrapStyle || {});
},
// ========== 滚动配置(同时兼容老数据 swiperConfig 和新字段 scrollMode) ==========
// 老数据 swiperConfig 字段:{ vertical, autoplay, circular, interval, duration, displayMultipleItems, swiperHeight }
// 新数据 scrollMode 字段:'none' | 'vertical'(默认 vertical,与老行为一致)
// ========== 滚动配置(兼容老数据 swiperConfig;现在用 CSS 跑马灯实现)==========
// scrollMode: 'none' 关闭滚动,显示普通列表;其他值均走 CSS 跑马灯
swiperConfig() {
return this.value.swiperConfig || {};
},
@@ -337,59 +315,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 报错)
// 安全的 display-multiple-items(避免窗口高度 > 内容高度时出现空白)
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;
// 跑马灯动画时长(秒)
// ① 若 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);
},
// 步进模式停留时长(ms),默认 1500
pauseDuration() {
var d = parseInt(this.swiperConfig.pauseDuration);
return d > 0 ? d : 1500;
// 跑马灯动画起点延迟(默认 1 秒,给首条内容 1 秒阅读时间)
marqueeDelay() {
var cfg = this.swiperConfig || {};
var explicit = parseFloat(cfg.marqueeDelay);
if (!isNaN(explicit) && explicit >= 0) return explicit;
return 1;
},
// 步进模式单次滚动动画时长(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),其中:
// - maxItemHeight: list 中最高 item 的自然高度(动态,见 _itemHeight)
// - itemGap: 用户配置的列表项间距(0-60rpx,默认 16)
// - N: displayMultipleItems(每页行数)
// 每个 swiper-item slot 高度 = (maxItemHeight + itemGap),精确等于 perRow。
// 所有 item 通过 CSS var --article-swiper-item-min-height 强制 min-height = maxItemHeight,
// 保证每条 item 视觉高度一致;swiper-item-content 的 padding-bottom = itemGap 制造
// 固定的视觉间距,无论 item 实际内容多高,间距恒为 itemGap。
// 早期版本在末尾 +10,被 swiper 平均分摊到每个 slot(10/N),导致间距 = itemGap + 10/N,
// 且随 N 变化不可控。现已删除。
// 3. list 尚未加载时(初始空状态)走 _staticMaxItemHeight 兜底(全字段最坏情况)。
swiperHeight() {
// 1. 显式设置了 swiperHeight (>0) → 用户强制覆盖
var configHeight = parseInt(this.swiperConfig.swiperHeight) || 0;
if (configHeight > 0) return configHeight;
// 2. (maxItemHeight + itemGap) × N,精确等于 perRow × N,不分摊 buffer
var itemGap = this._num(this.value.itemGap, 16);
return (this.maxItemHeight + itemGap) * this.safeDisplayMultipleItems;
// 内层 .marquee-track 的 inline style(动画时长 + 起点延迟)
// 其余动画属性走 CSS: name / timing-function / iteration-count
marqueeTrackStyle() {
return this.$util.objToStyle({
'animation-duration': this.marqueeDuration + 's',
'animation-delay': '-' + this.marqueeDelay + 's'
});
},
// list 中最高 item 的自然高度(动态) — swiperHeight + warpWrapStyle CSS 变量都用它
// list 为空时走 _staticMaxItemHeight 兜底
@@ -633,154 +591,21 @@ export default {
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;
}
// ========== 跑马灯触摸控制(CSS animation-play-state)==========
// 用户触摸 → animation-play-state: paused(给外层加 is-marquee-paused class)
onMarqueeTouchStart() {
this.marqueePaused = true;
if (this.marqueeResumeTimer) {
clearTimeout(this.marqueeResumeTimer);
this.marqueeResumeTimer = null;
}
// 下一帧
},
// 用户松手 → 2s 后继续滚动(给用户阅读时间; 动画会从暂停位置继续,不会跳变)
onMarqueeTouchEnd() {
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
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();
}
this.marqueeResumeTimer = setTimeout(function() {
self.marqueePaused = false;
}, 2000);
}
}
@@ -823,27 +648,55 @@ export default {
border-bottom-right-radius: var(--article-bottom-around-radius, 0);
.list-wrap {
// ============ 自动垂直滚动(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%;
// ============ 自动垂直滚动(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;
// 关键: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 {
&:last-of-type {
margin-bottom: var(--article-item-gap, 16rpx);
.marquee-track {
will-change: transform;
animation-name: diy-article-marquee-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// 触摸暂停(见 script 中 onMarqueeTouchStart 加 is-marquee-paused 状态)
&.is-marquee-paused .marquee-track {
animation-play-state: paused;
}
// 关键: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);
}
}
}
// 关键:marquee-track 内容 = list + list (两份),高度 = 2 × 单份高度
// translateY(-50%) = 平移一份高度,窗口中显示的内容与起点完全一致 → 循环无跳变
// 若写 -100%:两份内容都滚走,窗口变空,是错误的。
@keyframes diy-article-marquee-up {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(0, -50%, 0);
}
}
}
.item {
margin-bottom: var(--article-item-gap, 16rpx);
@@ -927,10 +780,21 @@ export default {
.iconfont {
/* 图标大小由 CSS 变量驱动 */
font-size: var(--article-meta-icon-size, 24rpx);
/* 图标与数字之间的视觉间隔:之前 6rpx 太挤,数字 1k / 1.2万 看起来"粘"在图标上;
10rpx 给到舒服的阅读节奏,与 view.icon(uni-icons 在 .num 内)的内边距不冲突 */
/* 兼容老的 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 {