diy-card-list.vue 列表项 :style="[itemStyle, {animationDelay}]" 数组绑定
按 2026-10-24 操作区同根因预防性消除(uni-app 静默丢弃数组绑定), 合并为
itemFinalStyle(index) 单对象绑定; 扫描全部 5 个卡片组件, 数组绑定清零
569 lines
14 KiB
Vue
569 lines
14 KiB
Vue
<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="item.card_id || 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="scrollNameStyle">{{ item.name || '未命名名片' }}</text>
|
||
<text v-if="infoLine(item)" class="scroll-meta" :style="scrollMetaStyle">{{ 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="item.card_id || index" class="wall-item fade-up"
|
||
:style="itemFinalStyle(index)" 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="gridNameStyle">{{ 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="metaStyle">{{ 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,
|
||
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
|
||
});
|
||
},
|
||
/** 网格/列表卡片姓名: 字号可配(默认 14px = 28rpx) */
|
||
gridNameStyle() {
|
||
return this.$util.objToStyle({
|
||
color: this.v.nameColor || CardTokens.color.text,
|
||
fontSize: px2rpx(num(this.v.nameSize, 14)) + 'rpx',
|
||
});
|
||
},
|
||
/** 横滑竖卡姓名: 比网格小一档(默认 13px = 26rpx, 与历史渲染一致) */
|
||
scrollNameStyle() {
|
||
return this.$util.objToStyle({
|
||
color: this.v.nameColor || CardTokens.color.text,
|
||
fontSize: px2rpx(num(this.v.nameSize, 14) - 1) + 'rpx',
|
||
});
|
||
},
|
||
/** 网格/列表卡片信息行: 字号可配(默认 11px = 22rpx) */
|
||
metaStyle() {
|
||
return this.$util.objToStyle({
|
||
color: this.v.infoColor || CardTokens.color.textSub,
|
||
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
|
||
});
|
||
},
|
||
/** 横滑竖卡信息行: 比网格小一档(默认 10px = 20rpx, 与历史渲染一致) */
|
||
scrollMetaStyle() {
|
||
return this.$util.objToStyle({
|
||
color: this.v.infoColor || CardTokens.color.textSub,
|
||
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
|
||
});
|
||
},
|
||
/** infoColor 纯色(箭头/空态用, 不带字号) */
|
||
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)',
|
||
// 人数徽标比信息行小一档(默认 10px = 20rpx, 与历史渲染一致)
|
||
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
|
||
});
|
||
},
|
||
moreStyle() {
|
||
return this.$util.objToStyle({
|
||
color: CardTokens.color.textLabel,
|
||
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
|
||
});
|
||
},
|
||
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: {
|
||
/**
|
||
* 列表项最终样式: itemStyle + 逐项入场动画延迟 合并输出单对象。
|
||
* uni-app 运行时会静默丢弃 :style="[A, B]" 数组绑定(2026-10-24 mini.vue
|
||
* 操作区同根因修复的先例), 数组形态禁止回归
|
||
*/
|
||
itemFinalStyle(index) {
|
||
return Object.assign({}, this.itemStyle, { animationDelay: (index * 60) + 'ms' });
|
||
},
|
||
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;
|
||
}
|
||
} else if (mode === 'link') {
|
||
// 平台统一「选择链接」: moreLink 是个对象({name,title,wap_url,page_key,page_params}),
|
||
// 走 $util.diyRedirectTo 由平台统一处理(内部页/外链/小程序/拨号 等)
|
||
const link = this.v.moreLink && typeof this.v.moreLink === 'object' ? this.v.moreLink : null;
|
||
if (!link || !link.name) {
|
||
uni.showToast({ title: '请先选择链接', icon: 'none' });
|
||
return;
|
||
}
|
||
this.$util.diyRedirectTo(link);
|
||
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>
|