Files
lucky_shop/components-diy/diy-card-list.vue
ZF sun 48a17a59f7 feat(diy-card): 名片 DIY 组件 2.0 视觉与功能升级(原生渲染, 禁 webview)
- diy-card-common.js(新): 设计 token 层 —— 颜色/字阶/间距/圆角/阴影/动效/
  触达尺寸 + px2rpx/flagOn/initialOf/avatarBg/themeGradient/visibleRows 助手
- diy-card-mini.vue(新): 共享名片卡基件 —— 封面+遮罩/渐变饰条/Logo/主题色
  头像(首字兜底)/资料行折叠(showRows 总开关+maxRows 上限+展开收起)/快捷操作
  芯片(首个主动作实心, 72rpx 触达)/二维码/详情按钮/骨架屏 shimmer/badge 插槽
- diy-card-entry.vue: 三预设(gradient/solid/minimal)×两布局(left/center)×
  图标装饰(内置字形/图片)×按钮四变体(solid/ghost/link/none)
- diy-card-list.vue: 三布局(grid 双列/list/scroll 横滑)+骨架屏+逐项 fade-up
  入场+标题行(计数/更多)+企业徽章+空态
- diy-card-show.vue / diy-card-self.vue: 改为基件包装; self 管登录态
  (hasToken watch 刷新)+兜底(fallbackMode)+空态 CTA+「我的名片」角标
- 修复死配置: 面板「显示资料行」showRows 此前被 C 端丢弃, 现由基件消费
  (历史配置无此字段时默认显示, 兼容 1.0)
- 规范: 禁 CSS 变量, 动态样式走 $util.objToStyle, rpx 尺寸
2026-10-21 09:56:51 +08:00

500 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)">
<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 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 width = this.layout === 'list' || this.columns === 1 ? '100%' : 'calc(50% - ' + px2rpx(gap / 2) + 'rpx)';
return this.$util.objToStyle({
width: width,
marginBottom: px2rpx(gap) + 'rpx',
});
},
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;
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>