Files
lucky_shop/components-diy/diy-card-list.vue
ZF sun 8bdd9f1291 fix(diy-card): 修复两处 WXSS 不兼容 calc() + MP 兼容契约测试
微信小程序 WXSS 不支持 calc(), 两处布局在小程序上会失效:

- mini.vue corner 档: max-width: calc(100% - 170rpx) →
  .mini-head { padding-right: 180rpx }(固定值, 浮层宽 120+偏移 20+呼吸 20)
- list.vue 网格: itemStyle width calc(50% - gap/2) →
  布局盒 50% 宽 + 左右各 half-gap padding, 视觉移入内层 .item-card
  (border/圆角/padding 随之下移, 布局盒透明只算 gutter)
- 新增 tests/diy-card-mp-compat.test.js 20 项: 全组件禁 calc()/var(--)
  (先剥注释再断言) + template 标签配平(栈式匹配, 兼容插槽嵌套 template)
  + 反例自测

注: 渐变角度 linear-gradient(deg,...) 有生产先例(detail/enterprise/
forward 均在生产小程序运行), 不在禁项; 真机渲染需 HBuilderX 构建验证。
验证: 46/46(三个名片测试文件) + npm test 仅剩两个存量失败
2026-10-21 11:58:35 +08:00

517 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<view data-component-name="diy-card-list" class="diy-card-list">
<view class="card-wall" :style="wallStyle">
<!-- 标题行: 标题 + 数量 + 查看全部 -->
<view v-if="showTitle || showCount || showMore" class="wall-head">
<text v-if="showTitle" class="wall-title" :style="titleStyle">{{ value.title || '团队名片' }}</text>
<text v-if="showCount && list.length" class="wall-count" :style="countStyle">{{ list.length }} 人</text>
<view class="head-spacer"></view>
<text v-if="showMore" class="wall-more" :style="moreStyle" hover-class="btn-press" @tap="goMore">{{ moreText || '查看全部' }} ›</text>
</view>
<!-- 骨架屏 -->
<view v-if="loading && !list.length" class="wall-skeleton">
<view v-for="n in skeletonCount" :key="'sk' + n" class="sk-item">
<view class="sk sk-avatar"></view>
<view class="sk-col">
<view class="sk sk-line" style="width:50%"></view>
<view class="sk sk-line" style="width:70%;margin-top:14rpx;"></view>
</view>
</view>
</view>
<!-- 横滑布局 -->
<scroll-view v-else-if="layout === 'scroll' && list.length" class="wall-scroll" scroll-x show-scrollbar="false">
<view v-for="(item, index) in list" :key="'s' + index" class="scroll-item fade-up" :style="scrollItemStyle" hover-class="btn-press" @tap="goDetail(item)">
<view class="item-avatar" :style="avatarStyle(item)">
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
</view>
<text class="scroll-name" :style="nameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="infoLine(item)" class="scroll-meta" :style="infoStyle">{{ infoLine(item) }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
</view>
</scroll-view>
<!-- 网格/列表布局 -->
<view v-else-if="list.length" :class="['wall-grid', layout === 'list' ? 'is-list' : 'is-grid']">
<view v-for="(item, index) in list" :key="'g' + index" class="wall-item fade-up"
:style="[itemStyle, { animationDelay: (index * 60) + 'ms' }]" hover-class="btn-press" @tap="goDetail(item)">
<!-- 内层卡片: 布局盒(wall-item)只负责 50% 宽+左右 gutter, 视觉在此层
(WXSS 不支持 calc(), 不能用 calc(50% - gap/2)) -->
<view class="item-card">
<view class="item-avatar" :style="avatarStyle(item)">
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
</view>
<view class="item-info">
<view class="name-row">
<text class="item-name" :style="nameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
</view>
<text v-if="infoLine(item)" class="item-meta" :style="infoStyle">{{ infoLine(item) }}</text>
</view>
<text v-if="showArrow" class="item-arrow" :style="infoStyle">›</text>
</view>
</view>
</view>
<!-- 空态 -->
<view v-else-if="!loading" class="wall-empty" :style="infoStyle">
<text class="empty-icon">🗂️</text>
<text class="empty-tip">{{ cardIdsConfigured ? '暂无可展示的名片' : '商家还未挑选名片' }}</text>
</view>
</view>
</view>
</template>
<script>
// 数字名片 · 团队名片墙(装修组件)
// 布局: grid(1/2 列) / list(整行) / scroll(横滑); 骨架屏 + 逐项入场 + 按压反馈
import { CardTokens, px2rpx, flagOn, num, initialOf, avatarBg } from './diy-card-common.js';
export default {
name: 'diy-card-list',
props: {
value: { type: Object }
},
data() {
return {
list: [],
loading: false,
};
},
computed: {
v() {
return this.value || {};
},
cardIds() {
return String(this.v.cardIds || '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
.slice(0, 20)
.join(',');
},
cardIdsConfigured() {
return String(this.v.cardIds || '').trim() !== '';
},
layout() {
const l = String(this.v.layout || 'grid');
return ['grid', 'list', 'scroll'].includes(l) ? l : 'grid';
},
columns() {
return Number(this.v.columns) === 1 ? 1 : 2;
},
showTitle() {
return String(this.v.title || '').trim() !== '';
},
showCount() {
return flagOn(this.v.showCount, false);
},
showMore() {
return flagOn(this.v.showMore, false);
},
moreText() {
return this.v.moreText || '';
},
showArrow() {
return flagOn(this.v.showArrow, true);
},
skeletonCount() {
return this.layout === 'list' ? 3 : this.columns * 2;
},
wallStyle() {
return this.$util.objToStyle({
background: this.v.bgColor || CardTokens.color.bg,
borderRadius: px2rpx(num(this.v.borderRadius, CardTokens.radius.md)) + 'rpx',
padding: px2rpx(num(this.v.padding, CardTokens.space.md)) + 'rpx',
});
},
titleStyle() {
return this.$util.objToStyle({ color: this.v.titleColor || CardTokens.color.text });
},
nameStyle() {
return this.$util.objToStyle({ color: this.v.nameColor || CardTokens.color.text });
},
infoStyle() {
return this.$util.objToStyle({ color: this.v.infoColor || CardTokens.color.textSub });
},
countStyle() {
return this.$util.objToStyle({
color: CardTokens.color.primary,
background: 'rgba(47,107,255,0.08)',
});
},
moreStyle() {
return this.$util.objToStyle({ color: CardTokens.color.textLabel });
},
itemStyle() {
const gap = num(this.v.gap, 10);
const isGrid = this.layout !== 'list' && this.columns !== 1;
const style = { marginBottom: px2rpx(gap) + 'rpx' };
if (isGrid) {
// WXSS 不支持 calc(): 50% 宽 + 左右各 half-gap 作 gutter(视觉在内层 item-card)
style.width = '50%';
style.paddingLeft = px2rpx(gap / 2) + 'rpx';
style.paddingRight = px2rpx(gap / 2) + 'rpx';
} else {
style.width = '100%';
}
return this.$util.objToStyle(style);
},
scrollItemStyle() {
return this.$util.objToStyle({
width: '260rpx',
marginRight: px2rpx(num(this.v.gap, 10)) + 'rpx',
});
},
},
watch: {
'value.cardIds'() {
this.fetchData();
},
},
mounted() {
this.fetchData();
},
methods: {
initialOf,
avatarStyle(item) {
return this.$util.objToStyle({ background: avatarBg(item) });
},
badgeStyle(item) {
return this.$util.objToStyle({ background: avatarBg(item) });
},
isEnterprise(item) {
return item && item.card_type === 'enterprise';
},
infoLine(item) {
const showJob = flagOn(this.v.showJob, true);
const showCompany = flagOn(this.v.showCompany, true);
const parts = [];
if (showJob && item.job_title) parts.push(item.job_title);
if (showCompany && item.company) parts.push(item.company);
return parts.join(' · ');
},
fetchData() {
if (this.cardIds === '') {
this.list = [];
return;
}
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/cards',
data: { card_ids: this.cardIds },
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {
this.list = res.data.list || [];
}
},
fail: () => {
this.loading = false;
},
});
},
goDetail(item) {
if (!item || !item.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: item.card_id });
},
goMore() {
const mode = String(this.v.moreLinkMode || 'list');
let url = '/pages_tool/card/list';
if (mode === 'contacts') {
url = '/pages_tool/card/contacts';
} else if (mode === 'custom') {
url = String(this.v.moreLinkUrl || '').trim();
if (url === '' || url.indexOf('/') !== 0) {
uni.showToast({ title: '自定义路径需以 / 开头', icon: 'none' });
return;
}
}
this.$util.redirectTo(url);
},
},
};
</script>
<style lang="scss" scoped>
.diy-card-list {
width: 100%;
.card-wall {
width: 100%;
box-sizing: border-box;
.wall-head {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.wall-title {
font-size: 32rpx;
font-weight: 700;
}
.wall-count {
font-size: 20rpx;
margin-left: 12rpx;
padding: 2rpx 12rpx;
border-radius: 999rpx;
}
.head-spacer {
flex: 1;
}
.wall-more {
font-size: 22rpx;
padding: 8rpx 0 8rpx 16rpx;
}
}
/* 网格/列表 */
.wall-grid {
&.is-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.wall-item {
box-sizing: border-box;
display: flex;
/* 视觉卡片层: 布局盒只算宽度与 gutter, 不画外观 */
.item-card {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 20rpx;
overflow: hidden;
}
.item-avatar {
flex-shrink: 0;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.avatar-img {
width: 100%;
height: 100%;
display: block;
}
.avatar-initial {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
}
}
.item-info {
flex: 1;
min-width: 0;
margin-left: 16rpx;
display: flex;
flex-direction: column;
.name-row {
display: flex;
align-items: center;
.item-name {
font-size: 28rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badge {
flex-shrink: 0;
margin-left: 10rpx;
font-size: 18rpx;
color: #ffffff;
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
}
.item-meta {
font-size: 22rpx;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.item-arrow {
flex-shrink: 0;
margin-left: 8rpx;
font-size: 32rpx;
}
}
}
/* 横滑 */
.wall-scroll {
width: 100%;
white-space: nowrap;
.scroll-item {
display: inline-flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 24rpx 16rpx;
position: relative;
.item-avatar {
width: 104rpx;
height: 104rpx;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.avatar-img {
width: 100%;
height: 100%;
display: block;
}
.avatar-initial {
font-size: 42rpx;
font-weight: 700;
color: #ffffff;
}
}
.scroll-name {
font-size: 26rpx;
font-weight: 600;
margin-top: 12rpx;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scroll-meta {
font-size: 20rpx;
margin-top: 4rpx;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
font-size: 18rpx;
color: #ffffff;
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
}
}
/* 空态 */
.wall-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
.empty-icon {
font-size: 56rpx;
}
.empty-tip {
font-size: 24rpx;
margin-top: 12rpx;
}
}
/* 骨架屏 */
.wall-skeleton {
.sk-item {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.sk-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
flex-shrink: 0;
}
.sk-col {
flex: 1;
margin-left: 16rpx;
}
}
}
}
.sk {
background: linear-gradient(90deg, #f2f3f5 25%, #e8eaee 37%, #f2f3f5 63%);
background-size: 400rpx 100%;
animation: sk-shimmer 1.2s linear infinite;
border-radius: 8rpx;
&.sk-line {
height: 24rpx;
}
}
}
.fade-up {
animation: card-fade-up 240ms ease both;
}
.btn-press {
opacity: 0.72;
transform: scale(0.98);
}
@keyframes sk-shimmer {
0% {
background-position: -200rpx 0;
}
100% {
background-position: 200rpx 0;
}
}
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(12rpx);
}
to {
opacity: 1;
transform: none;
}
}
</style>