feat(diy-article): add dynamic swiper height and date display control

重构文章组件的swiper高度计算逻辑,改为基于实际item高度动态计算;新增日期显示的配置控制,完善样式统一和布局适配,优化空列表兜底逻辑与间距控制。
This commit is contained in:
2026-06-15 11:54:21 +08:00
parent c1d4a68e26
commit 089da9181b

View File

@@ -43,7 +43,7 @@
<uni-icons type="star" size="12" :color="metaColor" />
<text>{{ item.dianzan_num || 0 }}</text>
</view>
<text class="date">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
</view>
</view>
</view>
@@ -81,7 +81,7 @@
<uni-icons type="star" size="12" :color="metaColor" />
<text>{{ item.dianzan_num || 0 }}</text>
</view>
<text class="date">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
</view>
</view>
</view>
@@ -188,6 +188,11 @@ export default {
'--article-view-more-icon-size': r(vm.iconSize, 24) + 'rpx',
// 列表项间距(rpx)
'--article-item-gap': r(v.itemGap, 16) + 'rpx',
// swiper 内 item 统一最小高度(rpx)= list 中最高 item 的自然高度
// CSS 中 .auto-scroll-swiper .item { min-height: var(--article-swiper-item-min-height) }
// 强制所有 item 高度统一,搭配 .swiper-item-content 的 padding-bottom = itemGap
// 制造恒定视觉间距,与 swiperHeight = (maxItemHeight + itemGap) × N 公式一致
'--article-swiper-item-min-height': this.maxItemHeight + 'rpx',
// 分割线颜色(主要用于 style-4)
'--article-divider-color': v.dividerColor || '#eeeeee',
// 标题字号 4 风格独立(rpx)
@@ -239,6 +244,9 @@ export default {
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 || {});
},
@@ -272,28 +280,36 @@ export default {
return actualCount > 0 ? Math.min(displayCount, actualCount) : 1;
},
// swiper 高度(rpx)优先级:
// 1. 老/新数据显式设置了 swiperConfig.swiperHeight(>0) → 配置值
// 2. 否则按 style 算单行高度 × 显示行数 + 行间距
// 注:风格1 perRow=210,正好容纳:
// 有图 item 200(图160+info 160 被 stretch+padding40)
// 无图 item ~200(标题/摘要单行截断+space-between 上下分布+padding40)
// swiper 整体 = 210*3+10 = 640rpx,行间紧凑,有图无图视觉高度统一。
// 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)
// 1. 显式设置了 swiperHeight (>0) → 用户强制覆盖
var configHeight = parseInt(this.swiperConfig.swiperHeight) || 0;
if (configHeight > 0) return configHeight;
// 2. 自动算:每行高度 × 显示行数 + 间距
// 注:swiper 内 item 已去掉 margin-bottom,swiper-item 高度 = perRow
var perRow;
switch (this.value.style) {
case 'style-1': perRow = 210; break; // 容纳有图200/无图200+10rpx 缓冲
case 'style-2': perRow = 540; break; // 图320 + info 220
case 'style-3': perRow = 300; break; // 图200 + info 100
case 'style-4': perRow = 120; break; // 纯文字
default: perRow = 200; break;
// 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 perRow * this.safeDisplayMultipleItems + 10; // N 行 + 10rpx 行间距总空间(更紧凑)
}
return Math.max.apply(null, this.list.map(function (item) {
return this._itemHeight(item);
}.bind(this)));
},
// 注: :class 一律在模板里用 :class="['static', dynamic]" 内联数组写法
// 不再用 listClass/itemClass computed (computed 返回 array 时,uni-app WXML 编译
// 输出 {{[listClass]}},运行时 toString 用逗号 join,产生 "list-wrap,style-1" 错误 class)
@@ -305,6 +321,103 @@ export default {
var n = parseInt(v);
return isNaN(n) ? fb : n;
},
// ========== 动态 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);
@@ -406,16 +519,33 @@ export default {
.auto-scroll-swiper {
width: 100%;
// swiper-item-content 占满 swiper-item,不另加 padding(避免与 item padding 叠加导致裁切)
// swiper-item-content:padding-bottom = itemGap 是制造 item 间恒定视觉间距的关键
// 不管 item 自然高度多少,两条 item 之间的空白永远是 itemGap,
// 而非「perRow - itemHeight」(后者随 item 高度变,间距不可控)。
// 顶部不补 padding,避免与 item padding 叠加导致裁切。
// 兜底:overflow:hidden 处理内容意外超过 maxItemHeight 的边界场景(防破坏布局)
.swiper-item-content {
padding: 0;
padding-top: 0;
padding-right: 0;
padding-bottom: var(--article-item-gap, 16rpx);
padding-left: 0;
box-sizing: border-box;
overflow: hidden;
}
// swiper 内 item 去掉 margin-bottom,避免被 swiper-item 高度裁切;
// 行间距由 swiperHeight 多余空间制造(perRow × 3 + 30)
// swiper 内 item:
// 1. margin-bottom: 0 — 间距由 .swiper-item-content 的 padding-bottom 承担,避免双重 margin
// 2. min-height = maxItemHeight(动态 CSS 变量)— 强制所有 item 视觉高度统一,短 item 自然撑高
// 3. align-items: flex-start — 覆盖默认 stretch,避免 info-wrap 被拉伸导致短内容被 space-between 拉开
.item {
margin-bottom: 0 !important;
min-height: var(--article-swiper-item-min-height, 200rpx);
align-items: flex-start;
}
// style-1 有图时,图片在 min-height item 内垂直居中(避免贴顶导致下方空一大块)
.item .article-img {
align-self: center;
}
}
@@ -437,6 +567,14 @@ export default {
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;
@@ -562,6 +700,11 @@ export default {
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;
@@ -571,22 +714,17 @@ export default {
}
}
// ========== 无图紧凑样式:让 item 高度跟有图一致 (~200rpx) ==========
// 背景:无图时 info-wrap 无 image 撑高,内容自然展开 ~232rpx + padding 40 = 272rpx,
// 远超有图时的 200rpx,导致 swiper-item 内行间空白巨大或被裁。
// 解决:无图时 info-wrap 显式 min-height: 160rpx(与有图时 image 撑高一致),
// 标题/摘要单行截断 + 去掉多余 margin,内容 ~124rpx 在 160rpx 容器内用
// space-between 上下分布(标题顶/摘要中/阅读底),与有图场景视觉高度统一。
// ========== 无图 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: 160rpx;
.title {
-webkit-line-clamp: 1;
margin-bottom: 0;
}
.desc {
-webkit-line-clamp: 1;
}
min-height: 240rpx;
.tag-list {
margin-top: 0;
}
@@ -635,6 +773,9 @@ export default {
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;