Files
lucky_shop/components-diy/diy-card-mini.vue

771 lines
22 KiB
Vue

<template>
<view data-component-name="diy-card-mini" class="card-mini">
<!-- 骨架屏: 首屏加载/切换名片 -->
<view v-if="loading" class="mini-skeleton" :style="wrapStyle">
<view class="sk-row">
<view class="sk sk-avatar"></view>
<view class="sk-col">
<view class="sk sk-line" style="width:55%"></view>
<view class="sk sk-line" style="width:35%;margin-top:16rpx;"></view>
</view>
</view>
<view class="sk sk-line" style="width:100%;margin-top:28rpx;"></view>
<view class="sk sk-line" style="width:72%;margin-top:16rpx;"></view>
<view class="sk sk-btn" style="margin-top:28rpx;"></view>
</view>
<!-- 空态: 未拿到名片 -->
<view v-else-if="!card" class="mini-empty" :style="wrapStyle">
<text class="empty-icon">💳</text>
<text class="empty-tip" :style="subTextStyle">{{ emptyTip || '暂无名片数据' }}</text>
<view v-if="emptyActionText" class="empty-btn" :style="emptyBtnStyle" hover-class="btn-press" @tap="onEmptyAction">
<text class="empty-btn-text">{{ emptyActionText }}</text>
</view>
</view>
<!-- 名片卡(白卡平铺; 「我的名片」角标压在左上) -->
<view v-else class="mini-card fade-up" :style="wrapStyle" :class="{ 'qr-at-corner': qrPos === 'corner' }">
<!-- 角标插槽(如 self 组件的「我的名片」) -->
<view class="mini-badge-slot">
<slot name="badge"></slot>
</view>
<!-- 封面图(可选) + 遮罩 -->
<image v-if="showCover" class="mini-cover" :src="$util.img(card.cover)" mode="aspectFill" />
<view v-if="showCover" class="mini-cover-scrim"></view>
<!-- 主题渐变饰条(可选) -->
<view v-if="showGradient" class="mini-gradient" :style="gradientStyle"></view>
<!-- Logo(可选, 右上; corner 档二维码占同一位, 自动避让) -->
<image v-if="showLogo && card.logo" class="mini-logo" :src="$util.img(card.logo)" mode="aspectFit" />
<!-- 二维码·右上角浮层(corner 档: 外观由 qrStyle 决定, 资料行自动让位; 点击预览名片) -->
<view v-if="showQrCorner" class="mini-qr mini-qr-corner" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<view class="mini-head" :style="cornerHeadStyle">
<view v-if="showAvatarBlock" class="mini-avatar" :style="avatarStyle">
<image v-if="card.avatar" class="avatar-img" :src="$util.img(card.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(card.name) }}</text>
</view>
<view class="mini-head-info">
<view class="name-row">
<text class="mini-name" :style="nameStyle">{{ card.name || '未命名名片' }}</text>
<text v-if="isEnterprise" class="mini-badge" :style="badgeStyle">企业</text>
</view>
<text v-if="card.card_title" class="mini-card-title" :style="subTextStyle">{{ card.card_title }}</text>
<text v-if="subLine" class="mini-sub" :style="subTextStyle">{{ subLine }}</text>
</view>
<!-- 二维码·头部右侧(head 档: 头像|信息|二维码 三段 flex, 零遮挡; 点击预览名片) -->
<view v-if="showQrHead" class="mini-qr mini-qr-head" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
</view>
<!-- 资料行(可折叠; 有字形字段显图标, 无字形退化为文字标签) -->
<view v-if="showRowsBlock && shownRows.length" class="mini-rows" :style="cornerClearStyle">
<view v-for="(row, i) in shownRows" :key="i" class="mini-row"
:class="{ 'row-tappable': rowTapable(row) }" :hover-class="rowTapable(row) ? 'btn-press' : 'none'" @tap="onRowTap(row)">
<text v-if="rowIcon(row)" class="row-icon iconfont" :class="rowIcon(row)" :style="labelStyle"></text>
<text v-else class="row-label" :style="labelStyle">{{ row.label }}</text>
<text class="row-value" :style="valueStyle">{{ row.value }}</text>
</view>
<view v-if="hasMoreRows" class="row-toggle" hover-class="btn-press" @tap="expanded = !expanded">
<text class="toggle-text" :style="toggleStyle">{{ expanded ? '收起资料' : '展开全部资料(' + hiddenCount + ')' }}</text>
</view>
</view>
<!-- 二维码·资料行下方(rows 档, 默认; 点击预览名片) -->
<view v-if="showQrRows" class="mini-qr" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<!-- 快捷操作(芯片; 2026-10-22 参考图定稿: 全部实心主题色胶囊(「打电话」同款);
尺寸/圆角走 .act-chip CSS 类, 内联只给背景色 —— 避免内联样式锁死导致 CSS 改动不生效) -->
<view v-if="showActs && acts.length" class="mini-acts" :style="cornerClearStyle">
<view v-for="(act, i) in acts" :key="i" class="act-chip"
:style="actStyle" hover-class="btn-press" @tap.stop="onAct(act)">
<text class="act-text" :style="actTextStyle">{{ act.label }}</text>
</view>
</view>
<!-- 二维码·主按钮上方(bottom 档; 点击预览名片) -->
<view v-if="showQrBottom" class="mini-qr" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<!-- 发送名片 -->
<view v-if="showDetailBtn" class="mini-detail" :style="detailBtnStyle" hover-class="btn-press" @tap.stop="onDetail">
<text class="detail-text">{{ detailBtnText || '发送名片' }}</text>
</view>
<!-- 发名片(对齐名片详情页: 主题色大按钮; 点击跳原生转发中转页拉起微信转发面板) -->
<view v-if="showSendBtn" class="mini-send" :style="sendBtnStyle" hover-class="btn-press" @tap.stop="onSend">
<text class="send-text">{{ sendBtnText || '发名片' }}</text>
</view>
</view>
</view>
</template>
<script>
// 数字名片 · 名片迷你卡基件(show/self 共用, 设计系统单一事实来源)
// 消费名片自身设计资产: cover 封面 / logo / theme_color / 渐变末色与角度 / card_title
import {
CardTokens, px2rpx, flagOn, num, initialOf, avatarBg, themeGradient, darkenColor, visibleRows,
rowIconFor, rowActOf, normalizeQrPosition, normalizeQrStyle, resolveQrSize, qrTransform, QR_SIZES,
} from './diy-card-common.js';
export default {
name: 'diy-card-mini',
props: {
card: { type: Object, default: null },
config: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false },
emptyTip: { type: String, default: '' },
emptyActionText: { type: String, default: '' },
},
data() {
return { expanded: false };
},
computed: {
c() {
return this.config || {};
},
wrapStyle() {
const c = this.c;
return this.$util.objToStyle({
background: flagOn(c.useCover, false) && this.card && this.card.cover ? 'transparent' : (c.bgColor || CardTokens.color.bg),
borderRadius: px2rpx(num(c.borderRadius, CardTokens.radius.md)) + 'rpx',
padding: px2rpx(num(c.padding, CardTokens.space.lg)) + 'rpx',
boxShadow: flagOn(c.showShadow, true) ? CardTokens.shadow.card : 'none',
});
},
showCover() {
return flagOn(this.c.useCover, false) && !!(this.card && this.card.cover);
},
showGradient() {
return flagOn(this.c.showGradient, false);
},
showLogo() {
// corner 档二维码占右上同一位, Logo 自动避让(面板有提示)
return flagOn(this.c.showLogo, false) && this.qrPos !== 'corner';
},
showActs() {
return flagOn(this.c.showQuick, true);
},
showRowsBlock() {
// 资料行总开关(面板「显示资料行」); 历史配置无此字段时默认显示, 兼容 1.0
return flagOn(this.c.showRows, true);
},
/** 二维码总条件: 开关 + 名片有二维码地址 */
showQrBase() {
return flagOn(this.c.showQr, false) && !!(this.card && this.card.qr_url);
},
qrPos() {
return normalizeQrPosition(this.c.qrPosition);
},
qrStyle() {
return normalizeQrStyle(this.c.qrStyle);
},
showQrTip() {
return flagOn(this.c.qrTipShow, true);
},
showQrRows() { return this.showQrBase && this.qrPos === 'rows'; },
showQrBottom() { return this.showQrBase && this.qrPos === 'bottom'; },
showQrHead() { return this.showQrBase && this.qrPos === 'head'; },
showQrCorner() { return this.showQrBase && this.qrPos === 'corner'; },
/** 头像开关(历史配置无此字段时默认显示) */
showAvatarBlock() {
return flagOn(this.c.showAvatar, true);
},
/**
* corner 档头部水平让位: 按浮层实际宽度反算(浮层宽[尺寸+衬底] + 右偏移20 + 呼吸20),
* 自定义大尺寸时自动跟随 —— 修复静态 180rpx 在 qrSize>120 时让位不足压住姓名区;
* 非 corner 档 / 二维码未启用时输出空对象(浮层不存在无需让位)
*/
cornerHeadStyle() {
if (this.qrPos !== 'corner' || !this.showQrBase) return {};
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const pad = this.qrStyle === 'plain' ? 0 : (this.qrStyle === 'gradient' ? 12 : 20);
return { paddingRight: (size + pad + 40) + 'rpx' };
},
/**
* corner 档资料行/操作区让位量: 按二维码实际占高反算
* (浮层 top 20 + 块高[尺寸+样式衬底+tip] - 头部 104 + 间隙 10), 防遮挡;
* 自定义大尺寸/关 tip 时自动跟随(不用 calc, 纯数值)
*/
cornerClearStyle() {
if (this.qrPos !== 'corner' || !this.showQrBase) return {};
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const pad = this.qrStyle === 'plain' ? 0 : (this.qrStyle === 'gradient' ? 6 : 10) * 2;
const tip = this.showQrTip ? 42 : 0;
const total = 20 + size + pad + tip;
return { marginTop: Math.max(24, total - 104 + 10) + 'rpx' };
},
/**
* 二维码块样式: 尺寸(自定义>档位自动) + 微调位移(transform, WXSS 支持;
* 不用 calc) + 外观(plain 原图 / card 白卡 / theme 主题色 / gradient 渐变)
*/
qrBlockStyle() {
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const style = {
width: size + 'rpx',
height: size + 'rpx',
};
const transform = qrTransform(this.c.qrOffsetX, this.c.qrOffsetY);
if (transform) style.transform = transform;
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
if (this.qrStyle === 'card') {
style.background = '#ffffff';
style.border = '2rpx solid #EEF0F3';
style.borderRadius = '12rpx';
style.padding = '10rpx';
} else if (this.qrStyle === 'theme') {
style.background = theme;
style.borderRadius = '12rpx';
style.padding = '10rpx';
} else if (this.qrStyle === 'gradient') {
const end = (this.card && this.card.gradient_end) || darkenColor(theme, 0.18);
style.background = 'linear-gradient(135deg, ' + theme + ' 0%, ' + end + ' 100%)';
style.borderRadius = '16rpx';
style.padding = '6rpx';
}
return this.$util.objToStyle(style);
},
showDetailBtn() {
return flagOn(this.c.showDetailBtn, true);
},
/** 发名片按钮(名片详情页同款主按钮; 默认关, 由 show 组件打开) */
showSendBtn() {
return flagOn(this.c.showSendBtn, false);
},
sendBtnText() {
return this.c.sendBtnText || '发名片';
},
sendBtnStyle() {
return this.$util.objToStyle({
background: themeGradient(this.card),
height: px2rpx(CardTokens.touch.btnHeight) + 'rpx',
borderRadius: px2rpx(CardTokens.radius.pill) + 'rpx',
});
},
qrTip() {
return this.c.qrTip || '';
},
detailBtnText() {
return this.c.detailBtnText || '';
},
maxRows() {
return num(this.c.maxRows, 4);
},
rowsInfo() {
if (!this.card) return { shown: [], hasMore: false };
return visibleRows(this.card.rows, this.expanded ? 0 : this.maxRows);
},
shownRows() {
return this.rowsInfo.shown;
},
hasMoreRows() {
return !this.expanded && this.rowsInfo.hasMore;
},
hiddenCount() {
return this.rowsInfo.rest.length;
},
acts() {
return this.card && Array.isArray(this.card.acts) ? this.card.acts : [];
},
isEnterprise() {
return this.card && this.card.card_type === 'enterprise';
},
subLine() {
const c = this.card || {};
return [c.job_title, c.company].filter(Boolean).join(' · ');
},
gradientStyle() {
return this.$util.objToStyle({ background: themeGradient(this.card) });
},
avatarStyle() {
return this.$util.objToStyle({ background: avatarBg(this.card) });
},
nameStyle() {
return this.$util.objToStyle({ color: this.c.nameColor || CardTokens.color.text });
},
subTextStyle() {
return this.$util.objToStyle({ color: this.c.infoColor || CardTokens.color.textSub });
},
labelStyle() {
return this.$util.objToStyle({ color: this.c.labelColor || CardTokens.color.textLabel });
},
valueStyle() {
return this.$util.objToStyle({ color: this.c.infoColor || CardTokens.color.textSub });
},
badgeStyle() {
// 企业徽标固定红底(参考图 2026-10-22 口径), 与头像底色解耦
return this.$util.objToStyle({ background: '#FA5151' });
},
toggleStyle() {
return this.$util.objToStyle({ color: (this.card && this.card.theme_color) || CardTokens.color.primary });
},
actStyle() {
// 快捷芯片统一实心主题色(「打电话」同款); 尺寸/圆角由 .act-chip CSS 类控制
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({ background: theme });
},
actTextStyle() {
return this.$util.objToStyle({ color: '#FFFFFF' });
},
detailBtnStyle() {
// 发送名片大按钮: 纯色主题实心(2026-10-22 参考图定稿, 不再用渐变);
// 高度 104rpx(用户反馈: 比触达标准 80rpx 加高一档, 参考图观感)
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({
background: theme,
height: '95rpx',
borderRadius: px2rpx(CardTokens.radius.pill) + 'rpx',
});
},
emptyBtnStyle() {
return this.$util.objToStyle({
background: CardTokens.color.primary,
height: px2rpx(CardTokens.touch.btnHeight) + 'rpx',
borderRadius: px2rpx(CardTokens.radius.pill) + 'rpx',
padding: '0 ' + px2rpx(CardTokens.space.xxl) + 'rpx',
});
},
},
methods: {
// 模板无法访问模块导入, 需挂到实例(list.vue 同款写法); 头像缺图时显示姓氏首字
initialOf,
/** 资料行图标(开关关时退化为文字标签; field 无字形同样退化) */
rowIcon(row) {
return flagOn(this.c.showRowIcons, true) ? rowIconFor(row && row.field) : '';
},
/** 资料行是否可点: 行点击开关 + 服务端 href 派生得出动作 */
rowTapable(row) {
return flagOn(this.c.rowTap, true) && !!rowActOf(row);
},
onRowTap(row) {
const act = rowActOf(row);
if (act) this.$emit('row-act', act);
},
onAct(act) {
this.$emit('act', act);
},
onDetail() {
this.$emit('detail', this.card);
},
/** 二维码点击 → 预览名片(进详情页; qrTapPreview 开关控制, 默认开) */
onQrTap() {
if (flagOn(this.c.qrTapPreview, true)) this.$emit('detail', this.card);
},
onSend() {
this.$emit('send', this.card);
},
onEmptyAction() {
this.$emit('empty-action');
},
},
};
</script>
<style lang="scss" scoped>
.card-mini {
width: 100%;
.mini-card {
position: relative;
overflow: hidden;
width: 100%;
box-sizing: border-box;
.mini-badge-slot {
position: absolute;
left: 0;
top: 0;
z-index: 4;
}
.mini-cover {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.mini-cover-scrim {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.45);
z-index: 1;
}
.mini-gradient {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 10rpx;
z-index: 2;
}
.mini-logo {
position: absolute;
right: 24rpx;
top: 24rpx;
width: 64rpx;
height: 64rpx;
z-index: 3;
}
.mini-head {
position: relative;
z-index: 2;
display: flex;
align-items: center;
.mini-avatar {
flex-shrink: 0;
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;
}
}
.mini-head-info {
flex: 1;
min-width: 0;
margin-left: 24rpx;
display: flex;
flex-direction: column;
.name-row {
display: flex;
align-items: center;
.mini-name {
font-size: 34rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mini-badge {
flex-shrink: 0;
margin-left: 12rpx;
font-size: 18rpx;
color: #ffffff;
padding: 2rpx 12rpx;
border-radius: 8rpx;
}
}
.mini-card-title {
font-size: 26rpx;
margin-top: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mini-sub {
font-size: 24rpx;
margin-top: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.mini-rows {
position: relative;
z-index: 2;
margin-top: 24rpx;
.mini-row {
display: flex;
line-height: 2;
.row-label {
flex-shrink: 0;
width: 96rpx;
font-size: 24rpx;
}
/* 图标列与文字标签列同宽, 保证值列左缘对齐(参考图口径) */
.row-icon {
flex-shrink: 0;
width: 96rpx;
text-align: center;
font-size: 28rpx;
}
.row-value {
flex: 1;
font-size: 24rpx;
word-break: break-all;
}
/* 可点行: hover-class 已给按压反馈, 此处仅标识可点(光标语义小程序无效, 保留 H5) */
&.row-tappable {
cursor: pointer;
}
}
.row-toggle {
margin-top: 8rpx;
display: flex;
justify-content: flex-end;
.toggle-text {
font-size: 22rpx;
font-weight: 500;
}
}
}
.mini-acts {
position: relative;
z-index: 2;
margin-top: 24rpx;
display: flex;
flex-wrap: wrap;
.act-chip {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
/* 小胶囊(2026-10-22 定稿): 显式给高/内距/圆角(内联只出背景色, 尺寸走本类);
72rpx 触达标准, 按用户反馈放大一档 */
height: 60rpx;
padding: 0 32rpx;
margin: 0 16rpx 16rpx 0;
border-radius: 36rpx;
.act-text {
font-size: 26rpx;
font-weight: 500;
}
}
}
.mini-qr {
position: relative;
z-index: 2;
margin-top: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
/* 块尺寸由 qrBlockStyle 内联给出(自定义/档位), 图片填满内容盒 */
.qr-img {
width: 100%;
height: 100%;
display: block;
}
.qr-tip {
font-size: 22rpx;
margin-top: 12rpx;
}
/* head 档: 头部第三段(头像|信息|二维码), 不占额外垂直节奏 */
&.mini-qr-head {
margin-top: 0;
margin-left: 20rpx;
flex-shrink: 0;
align-self: center;
.qr-tip {
font-size: 20rpx;
margin-top: 8rpx;
}
}
/* corner 档: 右上角浮层(外观衬底由 qrStyle 内联决定, 此处只定位于层次) */
&.mini-qr-corner {
position: absolute;
top: 20rpx;
right: 20rpx;
z-index: 3;
margin-top: 0;
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.12);
}
}
/* corner 档: 头部右侧让位由 cornerHeadStyle 按浮层实际宽度动态给出
(静态 180rpx 在自定义大尺寸 qrSize 下不足, 已废弃); WXSS 不支持 calc() */
&.qr-at-corner {
.mini-head {
position: relative;
z-index: 2;
}
}
.mini-detail {
position: relative;
z-index: 2;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
.detail-text {
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
}
/* 发名片主按钮(名片详情页 send-bar 同款观感) */
.mini-send {
position: relative;
z-index: 2;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
.send-text {
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
}
}
}
/* 空态 */
.mini-empty {
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48rpx 24rpx;
.empty-icon {
font-size: 64rpx;
}
.empty-tip {
font-size: 24rpx;
margin-top: 16rpx;
text-align: center;
}
.empty-btn {
margin-top: 28rpx;
display: flex;
align-items: center;
justify-content: center;
.empty-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
}
}
/* 骨架屏 */
.mini-skeleton {
width: 100%;
box-sizing: border-box;
.sk-row {
display: flex;
align-items: center;
.sk-avatar {
width: 104rpx;
height: 104rpx;
border-radius: 50%;
flex-shrink: 0;
}
.sk-col {
flex: 1;
margin-left: 24rpx;
}
}
.sk-btn {
height: 80rpx;
border-radius: 999rpx;
}
}
.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: 28rpx;
}
}
}
/* 入场动效(逐项延迟由父组件传 animation-delay) */
.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>