重构文章组件的跑马灯滚动逻辑,移除CSS keyframes动画实现,改用JS控制transform实现滚动,新增支持用户手动拖拽交互,统一连续/步进两种滚动模式的触摸控制逻辑,优化跨端表现和交互体验。
1309 lines
51 KiB
Vue
1309 lines
51 KiB
Vue
<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,
|
||
// 用户拖拽状态
|
||
isUserDragging: false,
|
||
dragStartY: 0,
|
||
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();
|
||
},
|
||
beforeDestroy() {
|
||
// 直接清理所有定时器/动画帧,避免在某些场景(如 HMR)下调用以 _ 开头方法时出现
|
||
// "is not a function" 的问题
|
||
this.stopAllMarquee();
|
||
},
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
},
|
||
// 模式切换(后端修改 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);
|
||
},
|
||
// 从当前位置恢复自动滚动(由 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;
|
||
|
||
.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> |