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

1158 lines
46 KiB
Vue
Raw 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']">
<!-- 自动垂直滚动(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="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>
</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"
@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>{{ 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>
</view>
<!-- 查看更多(仅非滚动模式显示) -->
<view class="view-more" v-if="viewMore.support && viewMore.control && list.length > 0"
:style="viewMoreStyle" @tap.stop="toListPage">
<text>{{ viewMore.text || '查看更多' }}</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">暂无相关文章</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
},
// ========== 滚动模式(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: {
// ========== 容器样式(与 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 和新字段 scrollMode) ==========
// 老数据 swiperConfig 字段:{ vertical, autoplay, circular, interval, duration, displayMultipleItems, swiperHeight }
// 新数据 scrollMode 字段:'none' | 'vertical'(默认 vertical,与老行为一致)
swiperConfig() {
return this.value.swiperConfig || {};
},
scrollMode() {
return this.value.scrollMode || 'vertical';
},
// 是否使用滚动模式
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),其中:
// - 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;
},
// 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),即「第一份末尾之后接续」
_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: 240rpx }
return Math.max(240, 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)
_staticMaxItemHeight() {
var style = this.value.style;
switch (style) {
case 'style-1': return 280; // 有图273/无图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);
},
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: ''
});
},
// ========== 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);
}
}
};
</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 {
// ============ 自动垂直滚动(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%;
// 关键: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);
}
}
}
.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);
margin-right: 6rpx;
}
}
.date {
margin-left: 16rpx;
}
}
.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: 横向卡片(图左文右) ==========
&.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;
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 样式:与有图视觉等高 (~280rpx) ==========
// 背景:开启全字段(标题+图+摘要+标签+阅读区含日期)后,有图 info 自然 233rpx +
// padding 40 = 273rpx;无图无 image 撑高,info 自然 ~233rpx,需 min-height 拉齐
// 到 240rpx,再加 padding 40 = 280rpx 与有图一致。
// 早期版本曾用 clamp:1 + margin:0 把无图压到 160rpx(匹配 image 高度),现 perRow
// 提到 280 后空间足够,放开 clamp:1 让无图也能展示完整标题/摘要,跟用户「内容
// 全显示」诉求一致;tag-list / read-wrap 的 margin 仍清零,避免无图时因
// space-between 分布与有图节奏不同。
.item.no-image {
.info-wrap {
min-height: 240rpx;
.tag-list {
margin-top: 0;
}
.read-wrap {
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>