Files
lucky_shop/components-diy/diy-card-list.vue
jing a08d0c2026 Merge remote-tracking branch 'origin/feat/camcard' into feat/camcard
# Conflicts:
#	components-diy/diy-card-mini.vue
#	components-diy/diy-card-show.vue
#	tests/diy-card-ab-visual.test.js
#	tests/diy-card-qr-style.test.js
2026-10-24 10:56:20 +08:00

561 lines
14 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="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="[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="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: {
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>