1. 将原swiper自动滚动方案替换为scroll-view实现,支持item自然高度,不再强制等高,解决短item下方留白问题 2. 新增连贯滚动和步进滚动两种自动滚动模式,适配不同场景需求 3. 修复H5端rpx单位不兼容问题,统一内联样式单位转换逻辑 4. 优化滚动容器高度计算,根据配置显示数量动态计算窗口高度 5. 新增触摸暂停恢复逻辑,提升用户交互体验
1158 lines
46 KiB
Vue
1158 lines
46 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']">
|
||
<!-- 自动垂直滚动(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> |