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 尺寸
This commit is contained in:
2026-10-21 09:56:51 +08:00
parent cc96e2ca13
commit 48a17a59f7
6 changed files with 1449 additions and 567 deletions

View File

@@ -0,0 +1,130 @@
/**
* 名片 DIY 组件 · 共享设计系统层
*
* 目标(UI Designer 设计系统先行原则):
* - 4 个组件共用同一套 token(颜色/字阶/间距/圆角/阴影/动效时长)
* - 通用 helpers(px2rpx/头像兜底色/显隐开关解析)集中一处, 避免组件间漂移
* - 严格遵循小程序规范: 禁 CSS 变量, 动态样式一律 $util.objToStyle, 单位 rpx
*
* 注: 本文件为纯 JS(无 wxss), 小程序/H5 通用。
*/
export const CardTokens = {
color: {
primary: '#2F6BFF', // 主题默认蓝(名片 wap 公开页同色系)
text: '#1F2329', // 主文字
textSub: '#8A8F99', // 次文字(公司/职位)
textLabel: '#B0B4BC', // 字段标签
line: '#EEF0F3', // 分割线
bg: '#FFFFFF', // 卡片底
skeleton: '#F2F3F5', // 骨架块
skeletonHi: '#E8EAEE', // 骨架高亮扫过
scrim: 'rgba(15, 23, 42, 0.45)', // 封面图遮罩(对比度保障)
},
radius: {
sm: 8,
md: 16,
lg: 24,
pill: 999,
},
/** 字阶(px, 输出时 ×2 转 rpx) */
fontSize: {
display: 40, // 入口主标题
title: 32, // 分组标题/名片姓名
name: 30,
meta: 24, // 公司/职位/字段值
caption: 22, // 辅助说明
},
/** 间距(px) */
space: {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
},
/** 阴影 */
shadow: {
card: '0 4rpx 16rpx rgba(15, 23, 42, 0.06)',
raised: '0 8rpx 24rpx rgba(15, 23, 42, 0.10)',
},
/** 动效时长(ms) */
duration: {
fast: 150,
normal: 240,
slow: 320,
},
/** 最小触达尺寸(rpx, 小程序可点性下限 44px≈88rpx, 芯片取 72rpx 高度) */
touch: {
chipHeight: 72,
btnHeight: 80,
avatar: 104,
},
};
/** px → rpx(组件配置字段均为 px 口径) */
export function px2rpx(v) {
const n = Number(v);
return isNaN(n) ? 0 : Math.round(n * 2);
}
/**
* 显隐开关解析: 兼容 true/false、1/0、"1"/"0"、"true"/"false"
* 未下发(undefined)时返回 fallback(由调用方决定默认值)
*/
export function flagOn(v, fallback) {
if (v === undefined || v === null || v === '') return fallback;
if (v === true || v === 1 || v === '1' || v === 'true') return true;
if (v === false || v === 0 || v === '0' || v === 'false') return false;
return !!v;
}
/** 姓名首字兜底(头像无图时) */
export function initialOf(name) {
const s = String(name || '').trim();
return s ? s.charAt(0) : '名';
}
/** 由字符串确定性派生色相(同名同色, 列表视觉稳定) */
export function hueOf(str) {
const s = String(str || '');
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) % 360;
}
return h;
}
/** 头像兜底底色: 主题色优先, 否则按名片确定性色相 */
export function avatarBg(card) {
const theme = card && card.theme_color;
if (theme) return theme;
const h = hueOf((card && (card.card_id || card.name)) || 'card');
return 'hsl(' + h + ', 62%, 55%)';
}
/** 主题渐变(头部饰条/主按钮): 有渐变末色用双色, 否则单色 */
export function themeGradient(card, angle) {
const theme = (card && card.theme_color) || CardTokens.color.primary;
const end = card && card.gradient_end;
const deg = (card && card.gradient_angle) || angle || 135;
return end
? 'linear-gradient(' + deg + 'deg, ' + theme + ' 0%, ' + end + ' 100%)'
: 'linear-gradient(' + deg + 'deg, ' + theme + ' 20%, ' + theme + ' 100%)';
}
/** 安全数字(配置字段) */
export function num(v, dft) {
const n = Number(v);
return isNaN(n) ? dft : n;
}
/** 字段行: 过滤空值, 限制条数(其余折叠) */
export function visibleRows(rows, max) {
const list = Array.isArray(rows) ? rows.filter(r => r && String(r.value || '').trim() !== '') : [];
if (max && max > 0 && list.length > max) {
return { shown: list.slice(0, max), rest: list.slice(max), hasMore: true };
}
return { shown: list, rest: [], hasMore: false };
}

View File

@@ -1,52 +1,152 @@
<template>
<view data-component-name="diy-card-entry" class="diy-card-entry" :style="wrapStyle" @tap="goLink">
<view class="entry-left">
<text class="entry-title" :style="textStyle">{{ value.title || '扫名片 · 谈合作' }}</text>
<view data-component-name="diy-card-entry" class="diy-card-entry" :class="'entry-' + stylePreset" :style="wrapStyle" hover-class="entry-press" @tap="goLink">
<!-- 图标装饰(可选) -->
<view v-if="showIcon" class="entry-icon" :style="iconWrapStyle">
<image v-if="iconType === 'image' && iconImage" class="icon-img" :src="$util.img(iconImage)" mode="aspectFit" />
<text v-else class="icon-glyph">{{ iconGlyph || '💼' }}</text>
</view>
<view class="entry-body">
<text class="entry-title" :style="titleStyle">{{ value.title || '扫名片 · 谈合作' }}</text>
<text v-if="value.subtitle" class="entry-sub" :style="subStyle">{{ value.subtitle }}</text>
</view>
<view v-if="value.btnText" class="entry-btn" :style="btnStyle">
<text class="entry-btn-text">{{ value.btnText }}</text>
<!-- 按钮(可选; link 模式为文字箭头) -->
<view v-if="buttonStyle !== 'none' && value.btnText" :class="['entry-btn', 'btn-' + buttonStyle]" :style="btnStyle" hover-class="btn-press" hover-stay-time="60">
<text class="entry-btn-text" :style="btnTextStyle">{{ value.btnText }}</text>
<text v-if="buttonStyle === 'link'" class="btn-arrow" :style="btnTextStyle">›</text>
</view>
</view>
</template>
<script>
// 数字名片 · 名片入口横幅(装修组件, 原生渲染不依赖 webview)
import DiyMinx from './minx.js';
import golbalConfig from '@/common/js/golbalConfig.js';
// 数字名片 · 名片入口横幅(装修组件)
// 预设: gradient(渐变) / solid(纯色+主题饰条) / minimal(透明轻量)
// 布局: left / center; 按钮: solid / ghost / link / none
import { CardTokens, px2rpx, flagOn, num } from './diy-card-common.js';
export default {
name: 'diy-card-entry',
props: {
value: { type: Object }
},
mixins: [DiyMinx, golbalConfig],
computed: {
v() {
return this.value || {};
},
stylePreset() {
const p = String(this.v.stylePreset || 'gradient');
return ['gradient', 'solid', 'minimal'].includes(p) ? p : 'gradient';
},
isCenter() {
return String(this.v.layout || 'left') === 'center';
},
showIcon() {
return flagOn(this.v.showIcon, false);
},
iconType() {
return String(this.v.iconType || 'icon') === 'image' ? 'image' : 'icon';
},
iconImage() {
return this.v.iconImage || '';
},
iconGlyph() {
return this.v.iconGlyph || '名';
},
buttonStyle() {
const b = String(this.v.buttonStyle || 'solid');
return ['solid', 'ghost', 'link', 'none'].includes(b) ? b : 'solid';
},
isGradient() {
return this.stylePreset === 'gradient';
},
isMinimal() {
return this.stylePreset === 'minimal';
},
themeColor() {
return this.v.bgColorStart || CardTokens.color.primary;
},
wrapStyle() {
const v = this.value || {};
const v = this.v;
const radius = px2rpx(num(v.borderRadius, CardTokens.radius.md)) + 'rpx';
const pad = px2rpx(num(v.padding, CardTokens.space.lg)) + 'rpx';
if (this.isMinimal) {
return this.$util.objToStyle({
background: 'transparent',
borderRadius: radius,
padding: pad,
boxShadow: 'none',
});
}
if (this.isGradient) {
return this.$util.objToStyle({
background: 'linear-gradient(135deg, ' + (v.bgColorStart || CardTokens.color.primary) + ' 0%, ' + (v.bgColorEnd || '#1E40AF') + ' 100%)',
borderRadius: radius,
padding: pad,
minHeight: '120rpx',
});
}
// solid: 主题色底 + 左侧亮饰条
return this.$util.objToStyle({
background: 'linear-gradient(135deg, ' + (v.bgColorStart || '#2F6BFF') + ' 0%, ' + (v.bgColorEnd || '#1E40AF') + ' 100%)',
borderRadius: this.px2rpx(v.borderRadius || 12) + 'rpx',
padding: this.px2rpx(v.padding || 16) + 'rpx ' + this.px2rpx(v.padding || 16) + 'rpx'
background: v.bgColorStart || CardTokens.color.primary,
borderRadius: radius,
padding: pad,
borderLeft: '8rpx solid ' + (v.bgColorEnd || 'rgba(255,255,255,0.35)'),
minHeight: '120rpx',
});
},
textStyle() {
return this.$util.objToStyle({ color: (this.value || {}).textColor || '#FFFFFF' });
iconWrapStyle() {
return this.$util.objToStyle({
background: this.isGradient || !this.isMinimal ? 'rgba(255,255,255,0.18)' : 'rgba(47,107,255,0.10)',
});
},
titleStyle() {
return this.$util.objToStyle({
color: this.v.textColor || (this.isMinimal ? CardTokens.color.text : '#FFFFFF'),
});
},
subStyle() {
return this.$util.objToStyle({ color: (this.value || {}).textColor || '#FFFFFF', opacity: 0.85 });
return this.$util.objToStyle({
color: this.v.textColor || (this.isMinimal ? CardTokens.color.textSub : '#FFFFFF'),
opacity: this.isMinimal ? 1 : 0.85,
});
},
btnStyle() {
return this.$util.objToStyle({ background: (this.value || {}).accentColor || '#FFEB3B' });
}
const v = this.v;
const radius = px2rpx(CardTokens.radius.pill) + 'rpx';
const pad = '0 ' + px2rpx(CardTokens.space.xl) + 'rpx';
if (this.buttonStyle === 'ghost') {
return this.$util.objToStyle({
border: '2rpx solid ' + (v.accentColor || '#FFEB3B'),
borderRadius: radius,
padding: pad,
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
});
}
if (this.buttonStyle === 'link') {
return this.$util.objToStyle({ padding: '0 0 0 ' + px2rpx(CardTokens.space.sm) + 'rpx' });
}
return this.$util.objToStyle({
background: v.accentColor || '#FFEB3B',
borderRadius: radius,
padding: pad,
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
});
},
btnTextStyle() {
const v = this.v;
if (this.buttonStyle === 'ghost') {
return this.$util.objToStyle({ color: v.accentColor || '#FFEB3B' });
}
if (this.buttonStyle === 'link') {
return this.$util.objToStyle({ color: v.textColor || CardTokens.color.primary });
}
return this.$util.objToStyle({ color: '#333333' });
},
},
methods: {
px2rpx(v) {
const n = Number(v);
return isNaN(n) ? 0 : Math.round(n * 2);
},
goLink() {
const v = this.value || {};
const v = this.v;
let url = '';
if (v.linkMode === 'contacts') {
url = '/pages_tool/card/contacts';
@@ -64,8 +164,8 @@ export default {
return;
}
this.$util.redirectTo(url);
}
}
},
},
};
</script>
@@ -75,10 +175,39 @@ export default {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
.entry-left {
&.entry-center {
justify-content: center;
text-align: center;
.entry-body {
align-items: center;
}
}
.entry-icon {
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.icon-img {
width: 100%;
height: 100%;
display: block;
}
.icon-glyph {
font-size: 48rpx;
}
}
.entry-body {
flex: 1;
min-width: 0;
display: flex;
@@ -88,6 +217,11 @@ export default {
font-size: 32rpx;
font-weight: 700;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.entry-sub {
@@ -105,14 +239,29 @@ export default {
.entry-btn {
flex-shrink: 0;
margin-left: 20rpx;
padding: 12rpx 28rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
.entry-btn-text {
font-size: 24rpx;
font-weight: 600;
color: #333333;
}
.btn-arrow {
font-size: 28rpx;
margin-left: 2rpx;
}
}
}
.entry-press {
transform: scale(0.985);
opacity: 0.92;
}
.btn-press {
opacity: 0.72;
}
</style>

View File

@@ -1,24 +1,61 @@
<template>
<view data-component-name="diy-card-list" class="diy-card-list">
<view class="card-wall" :style="wallStyle">
<text v-if="value.title" class="wall-title" :style="titleStyle">{{ value.title }}</text>
<!-- 标题行: 标题 + 数量 + 查看全部 -->
<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="list.length" class="wall-grid">
<view v-for="(item, index) in list" :key="index" class="wall-item" :style="itemStyle" @tap="goDetail(item)">
<!-- 骨架屏 -->
<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" :style="initialStyle(item)">{{ (item.name || '名').substring(0, 1) }}</text>
<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">
<text class="item-name" :style="nameStyle">{{ item.name || '未命名名片' }}</text>
<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 class="wall-empty" :style="infoStyle">
<text>{{ emptyTip }}</text>
<!-- 空态 -->
<view v-else-if="!loading" class="wall-empty" :style="infoStyle">
<text class="empty-icon">🗂️</text>
<text class="empty-tip">{{ cardIdsConfigured ? '暂无可展示的名片' : '商家还未挑选名片' }}</text>
</view>
</view>
</view>
@@ -26,114 +63,166 @@
<script>
// 数字名片 · 团队名片墙(装修组件)
// 数据口径: 商家在设计面板手动挑选 card_ids, 走 /card/api/diy/cards 公开摘要
import DiyMinx from './minx.js';
import golbalConfig from '@/common/js/golbalConfig.js';
// 布局: 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 }
},
mixins: [DiyMinx, golbalConfig],
data() {
return {
list: []
list: [],
loading: false,
};
},
computed: {
v() {
return this.value || {};
},
cardIds() {
return String((this.value || {}).cardIds || '')
return String(this.v.cardIds || '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
.slice(0, 20)
.join(',');
},
showArrow() {
const flag = this.getDiyToggle(['showArrow']);
return flag === undefined ? true : flag;
cardIdsConfigured() {
return String(this.v.cardIds || '').trim() !== '';
},
emptyTip() {
const v = this.value || {};
return String(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() {
const v = this.value || {};
return this.$util.objToStyle({
background: v.bgColor || '#FFFFFF',
borderRadius: this.px2rpx(v.borderRadius || 12) + 'rpx',
padding: this.px2rpx(v.padding || 12) + 'rpx'
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.value || {}).titleColor || '#333333' });
return this.$util.objToStyle({ color: this.v.titleColor || CardTokens.color.text });
},
nameStyle() {
return this.$util.objToStyle({ color: (this.value || {}).nameColor || '#1F2329' });
return this.$util.objToStyle({ color: this.v.nameColor || CardTokens.color.text });
},
infoStyle() {
return this.$util.objToStyle({ color: (this.value || {}).infoColor || '#888888' });
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 v = this.value || {};
const gap = Number(v.gap) || 10;
const cols = Number(v.columns) === 1 ? 1 : 2;
const width = cols === 1 ? '100%' : 'calc(50% - ' + this.px2rpx(gap / 2) + 'rpx)';
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: this.px2rpx(gap) + 'rpx'
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: {
px2rpx(v) {
const n = Number(v);
return isNaN(n) ? 0 : Math.round(n * 2);
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;
},
});
},
avatarStyle(item) {
return this.$util.objToStyle({ background: item.theme_color || '#2F6BFF' });
},
initialStyle(item) {
return this.$util.objToStyle({ color: '#FFFFFF' });
},
infoLine(item) {
const v = this.value || {};
const parts = [];
const showJob = this.getDiyToggle(['showJob']);
const showCompany = this.getDiyToggle(['showCompany']);
if ((showJob === undefined || showJob) && item.job_title) parts.push(item.job_title);
if ((showCompany === undefined || showCompany) && item.company) parts.push(item.company);
return parts.join(' · ');
},
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>
@@ -145,86 +234,266 @@ export default {
width: 100%;
box-sizing: border-box;
.wall-title {
display: block;
font-size: 32rpx;
font-weight: 700;
margin-bottom: 20rpx;
}
.wall-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.wall-item {
box-sizing: border-box;
.wall-head {
display: flex;
align-items: center;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 20rpx;
overflow: hidden;
margin-bottom: 20rpx;
.item-avatar {
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
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;
}
}
.item-info {
flex: 1;
min-width: 0;
margin-left: 16rpx;
display: flex;
flex-direction: column;
.item-name {
font-size: 28rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-meta {
font-size: 22rpx;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.item-arrow {
flex-shrink: 0;
margin-left: 8rpx;
.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 {
padding: 30rpx 0;
font-size: 24rpx;
text-align: center;
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>

View File

@@ -0,0 +1,582 @@
<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">
<!-- 角标插槽(如 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(可选, 右上) -->
<image v-if="showLogo && card.logo" class="mini-logo" :src="$util.img(card.logo)" mode="aspectFit" />
<view class="mini-head">
<view 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>
</view>
<!-- 资料行(可折叠) -->
<view v-if="showRowsBlock && shownRows.length" class="mini-rows">
<view v-for="(row, i) in shownRows" :key="i" class="mini-row">
<text 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>
<!-- 快捷操作(芯片, 最小触达 72rpx; 首个为主动作) -->
<view v-if="showActs && acts.length" class="mini-acts">
<view v-for="(act, i) in acts" :key="i" class="act-chip"
:style="i === 0 ? actPrimaryStyle : actGhostStyle" hover-class="btn-press" @tap.stop="onAct(act)">
<text class="act-text" :style="i === 0 ? actPrimaryTextStyle : actGhostTextStyle">{{ act.label }}</text>
</view>
</view>
<!-- 二维码(可选) -->
<view v-if="showQr && card.qr_url" class="mini-qr">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text 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>
</view>
</template>
<script>
// 数字名片 · 名片迷你卡基件(show/self 共用, 设计系统单一事实来源)
// 消费名片自身设计资产: cover 封面 / logo / theme_color / 渐变末色与角度 / card_title
import {
CardTokens, px2rpx, flagOn, num, initialOf, avatarBg, themeGradient, visibleRows,
} 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() {
return flagOn(this.c.showLogo, false);
},
showActs() {
return flagOn(this.c.showQuick, true);
},
showRowsBlock() {
// 资料行总开关(面板「显示资料行」); 历史配置无此字段时默认显示, 兼容 1.0
return flagOn(this.c.showRows, true);
},
showQr() {
return flagOn(this.c.showQr, false);
},
showDetailBtn() {
return flagOn(this.c.showDetailBtn, true);
},
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() {
return this.$util.objToStyle({ background: avatarBg(this.card) });
},
toggleStyle() {
return this.$util.objToStyle({ color: (this.card && this.card.theme_color) || CardTokens.color.primary });
},
actPrimaryStyle() {
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({
background: theme,
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
padding: '0 ' + px2rpx(CardTokens.space.xl) + 'rpx',
borderRadius: px2rpx(CardTokens.radius.pill) + 'rpx',
});
},
actPrimaryTextStyle() {
return this.$util.objToStyle({ color: '#FFFFFF' });
},
actGhostStyle() {
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({
background: 'transparent',
border: '2rpx solid ' + theme,
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
padding: '0 ' + px2rpx(CardTokens.space.lg) + 'rpx',
borderRadius: px2rpx(CardTokens.radius.pill) + 'rpx',
});
},
actGhostTextStyle() {
return this.$util.objToStyle({ color: (this.card && this.card.theme_color) || CardTokens.color.primary });
},
detailBtnStyle() {
return this.$util.objToStyle({
background: themeGradient(this.card),
height: px2rpx(CardTokens.touch.btnHeight) + 'rpx',
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: {
onAct(act) {
this.$emit('act', act);
},
onDetail() {
this.$emit('detail', 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-value {
flex: 1;
font-size: 24rpx;
word-break: break-all;
}
}
.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;
margin: 0 16rpx 16rpx 0;
.act-text {
font-size: 24rpx;
font-weight: 500;
}
}
}
.mini-qr {
position: relative;
z-index: 2;
margin-top: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
.qr-img {
width: 180rpx;
height: 180rpx;
display: block;
}
.qr-tip {
font-size: 22rpx;
margin-top: 12rpx;
}
}
.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;
}
}
}
/* 空态 */
.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>

View File

@@ -1,65 +1,55 @@
<template>
<view data-component-name="diy-card-self" class="diy-card-self">
<!-- 未登录 + 登录引导兜底 -->
<view v-if="!hasToken && fallbackMode === 'none'" class="self-login" :style="boxStyle" @tap="goLogin">
<view v-if="!hasToken && fallbackMode === 'none'" class="self-login" :style="boxStyle" @tap="goLogin" hover-class="btn-press">
<text class="login-tip" :style="nameStyle">{{ value.loginTip || '登录后这里展示我的名片' }}</text>
<view class="login-btn" :style="detailStyle">
<view class="login-btn" :style="loginBtnStyle">
<text class="login-btn-text">{{ value.loginBtnText || '去登录' }}</text>
</view>
</view>
<!-- 名片卡(已登录=本人名片 / 未登录=兜底名片) -->
<view v-else-if="card" class="self-box" :style="boxStyle">
<view v-if="isSelf" class="self-badge" :style="badgeStyle">
<text class="badge-text">我的名片</text>
</view>
<view class="card-head">
<view class="head-avatar" :style="avatarStyle">
<image v-if="card.avatar" class="avatar-img" :src="$util.img(card.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ (card.name || '名').substring(0, 1) }}</text>
<diy-card-mini
v-else-if="card"
:card="card"
:config="miniConfig"
:loading="loading"
@act="doAct"
@detail="goDetail"
>
<template v-if="isSelf" slot="badge">
<view class="self-badge" :style="badgeStyle">
<text class="badge-text">我的名片</text>
</view>
<view class="head-info">
<text class="head-name" :style="nameStyle">{{ card.name || '未命名名片' }}</text>
<text v-if="subLine" class="head-sub" :style="infoStyle">{{ subLine }}</text>
</view>
</view>
<view v-if="showQuick && card.acts && card.acts.length" class="card-acts">
<view v-for="(act, index) in card.acts" :key="index" class="act-btn" :style="actStyle" @tap.stop="doAct(act)">
<text class="act-text" :style="actTextStyle">{{ act.label }}</text>
</view>
</view>
<view v-if="showDetailBtn" class="detail-btn" :style="detailStyle" @tap.stop="goDetail">
<text class="detail-text">{{ value.detailBtnText || '查看完整名片' }}</text>
</view>
</view>
</template>
</diy-card-mini>
<!-- 已登录但没有任何名片可显示 -->
<view v-else class="self-empty" :style="boxStyle">
<text class="empty-tip" :style="infoStyle">登录后即可展示我的名片</text>
<view v-else-if="!loading" class="self-empty" :style="boxStyle">
<text class="empty-icon">🪪</text>
<text class="empty-tip" :style="subTextStyle">{{ value.noCardTip || '登录后即可展示我的名片' }}</text>
<view class="empty-btn" :style="loginBtnStyle" hover-class="btn-press" @tap="goCardList">
<text class="empty-btn-text">{{ value.noCardBtnText || '去看看名片' }}</text>
</view>
</view>
</view>
</template>
<script>
// 数字名片 · 我的名片(装修组件)
// 运行时判定: 操作者已登录 → 本人名片(optionalAuth); 未登录 → 配置的兜底名片
// 运行时判定: 已登录 → 本人名片(optionalAuth); 未登录 → 配置的兜底名片
// fallbackMode: enterprise=店铺企业名片 / card=指定 fallbackCardId / none=登录引导
import DiyMinx from './minx.js';
import golbalConfig from '@/common/js/golbalConfig.js';
// 视觉基件 diy-card-mini; 本文件管登录态/兜底/空态 CTA
export default {
name: 'diy-card-self',
props: {
value: { type: Object }
},
mixins: [DiyMinx, golbalConfig],
data() {
return {
card: null,
isSelf: false
isSelf: false,
loading: false,
};
},
computed: {
@@ -74,48 +64,55 @@ export default {
const m = String((this.value || {}).fallbackMode || 'enterprise');
return ['enterprise', 'card', 'none'].includes(m) ? m : 'enterprise';
},
showQuick() {
const flag = this.getDiyToggle(['showQuick']);
return flag === undefined ? true : flag;
},
showDetailBtn() {
const flag = this.getDiyToggle(['showDetailBtn']);
return flag === undefined ? true : flag;
},
subLine() {
const c = this.card || {};
return [c.job_title, c.company].filter(Boolean).join(' · ');
miniConfig() {
const v = this.value || {};
return {
bgColor: v.bgColor,
nameColor: v.nameColor,
infoColor: v.infoColor,
labelColor: v.labelColor,
borderRadius: v.borderRadius,
padding: v.padding,
showQuick: v.showQuick,
showDetailBtn: v.showDetailBtn,
detailBtnText: v.detailBtnText,
// 2.0 新增视觉开关
useCover: v.useCover,
showGradient: v.showGradient,
showLogo: v.showLogo,
showShadow: v.showShadow,
showRows: v.showRows,
maxRows: v.maxRows,
showQr: v.showQr,
qrTip: v.qrTip,
};
},
boxStyle() {
const v = this.value || {};
return this.$util.objToStyle({
background: v.bgColor || '#FFFFFF',
borderRadius: this.px2rpx(v.borderRadius || 16) + 'rpx',
padding: this.px2rpx(v.padding || 16) + 'rpx'
padding: this.px2rpx(v.padding || 16) + 'rpx',
boxShadow: '0 4rpx 16rpx rgba(15,23,42,0.06)',
});
},
avatarStyle() {
return this.$util.objToStyle({ background: (this.card || {}).theme_color || '#2F6BFF' });
},
nameStyle() {
return this.$util.objToStyle({ color: (this.value || {}).nameColor || '#1F2329' });
},
infoStyle() {
return this.$util.objToStyle({ color: (this.value || {}).infoColor || '#888888' });
},
actStyle() {
const theme = (this.card || {}).theme_color || '#2F6BFF';
return this.$util.objToStyle({ borderColor: theme });
},
actTextStyle() {
return this.$util.objToStyle({ color: (this.card || {}).theme_color || '#2F6BFF' });
},
detailStyle() {
return this.$util.objToStyle({ background: (this.card || {}).theme_color || '#2F6BFF' });
subTextStyle() {
return this.$util.objToStyle({ color: (this.value || {}).infoColor || '#8A8F99' });
},
badgeStyle() {
return this.$util.objToStyle({ background: (this.card || {}).theme_color || '#2F6BFF' });
}
},
loginBtnStyle() {
return this.$util.objToStyle({
background: (this.card || {}).theme_color || '#2F6BFF',
height: '80rpx',
borderRadius: '999rpx',
padding: '0 48rpx',
});
},
},
watch: {
hasToken() {
@@ -126,7 +123,7 @@ export default {
},
'value.fallbackCardId'() {
this.fetchData();
}
},
},
mounted() {
this.fetchData();
@@ -141,13 +138,15 @@ export default {
this.card = null;
return;
}
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/mycard',
data: {
fallback_mode: this.fallbackMode,
fallback_card_id: String((this.value || {}).fallbackCardId || '').trim()
fallback_card_id: String((this.value || {}).fallbackCardId || '').trim(),
},
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {
this.card = res.data.card || null;
this.isSelf = res.data.is_self == 1;
@@ -155,12 +154,20 @@ export default {
this.card = null;
this.isSelf = false;
}
}
},
fail: () => {
this.loading = false;
this.card = null;
this.isSelf = false;
},
});
},
goLogin() {
this.$util.redirectTo('/pages_tool/login/login');
},
goCardList() {
this.$util.redirectTo('/pages_tool/card/list');
},
doAct(act) {
if (!act || !act.value) return;
if (act.type === 'tel') {
@@ -168,7 +175,7 @@ export default {
} else if (act.type === 'wx') {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' })
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
});
} else if (act.type === 'mail') {
// #ifdef H5
@@ -177,21 +184,21 @@ export default {
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '邮箱已复制', icon: 'none' })
success: () => uni.showToast({ title: '邮箱已复制', icon: 'none' }),
});
// #endif
} else {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '已复制', icon: 'none' })
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
});
}
},
goDetail() {
if (!this.card || !this.card.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: this.card.card_id });
}
}
goDetail(card) {
if (!card || !card.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
},
},
};
</script>
@@ -200,107 +207,19 @@ export default {
width: 100%;
position: relative;
.self-box {
width: 100%;
box-sizing: border-box;
/* 「我的名片」角标: 覆在基件右上(基件已留 logo 位, badge 靠左一点避免重叠) */
.self-badge {
position: absolute;
top: 10rpx;
left: 10rpx;
z-index: 10;
border-radius: 999rpx 0 16rpx 0;
padding: 8rpx 20rpx;
.self-badge {
position: absolute;
top: 0;
right: 0;
border-radius: 0 16rpx 0 16rpx;
padding: 6rpx 20rpx;
.badge-text {
font-size: 20rpx;
color: #ffffff;
font-weight: 600;
}
}
.card-head {
display: flex;
align-items: center;
.head-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: 44rpx;
font-weight: 700;
color: #ffffff;
}
}
.head-info {
flex: 1;
min-width: 0;
margin-left: 24rpx;
display: flex;
flex-direction: column;
.head-name {
font-size: 34rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head-sub {
font-size: 24rpx;
margin-top: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.card-acts {
margin-top: 24rpx;
display: flex;
flex-wrap: wrap;
.act-btn {
border: 2rpx solid;
border-radius: 999rpx;
padding: 10rpx 28rpx;
margin: 0 16rpx 16rpx 0;
.act-text {
font-size: 24rpx;
font-weight: 500;
}
}
}
.detail-btn {
margin-top: 12rpx;
border-radius: 999rpx;
padding: 16rpx 0;
display: flex;
align-items: center;
justify-content: center;
.detail-text {
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
.badge-text {
font-size: 20rpx;
color: #ffffff;
font-weight: 600;
}
}
@@ -311,6 +230,12 @@ export default {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48rpx 24rpx;
.empty-icon {
font-size: 64rpx;
}
.login-tip,
.empty-tip {
@@ -319,17 +244,25 @@ export default {
text-align: center;
}
.login-btn {
margin-top: 24rpx;
border-radius: 999rpx;
padding: 14rpx 48rpx;
.login-btn,
.empty-btn {
margin-top: 28rpx;
display: flex;
align-items: center;
justify-content: center;
.login-btn-text {
font-size: 24rpx;
color: #ffffff;
.login-btn-text,
.empty-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
}
}
}
.btn-press {
opacity: 0.72;
transform: scale(0.98);
}
</style>

View File

@@ -1,138 +1,82 @@
<template>
<view data-component-name="diy-card-show" class="diy-card-show">
<view v-if="card" class="card-box" :style="boxStyle">
<view class="card-head">
<view class="head-avatar" :style="avatarStyle">
<image v-if="card.avatar" class="avatar-img" :src="$util.img(card.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ (card.name || '名').substring(0, 1) }}</text>
</view>
<view class="head-info">
<text class="head-name" :style="nameStyle">{{ card.name || '未命名名片' }}</text>
<text v-if="subLine" class="head-sub" :style="infoStyle">{{ subLine }}</text>
</view>
</view>
<view v-if="showRows && card.rows && card.rows.length" class="card-rows">
<view v-for="(row, index) in card.rows" :key="index" class="row-line">
<text class="row-label" :style="labelStyle">{{ row.label }}</text>
<text class="row-value" :style="infoStyle">{{ row.value }}</text>
</view>
</view>
<view v-if="showQuick && card.acts && card.acts.length" class="card-acts">
<view v-for="(act, index) in card.acts" :key="index" class="act-btn" :style="actStyle" @tap.stop="doAct(act)">
<text class="act-text" :style="actTextStyle">{{ act.label }}</text>
</view>
</view>
<view v-if="showDetailBtn" class="detail-btn" :style="detailStyle" @tap.stop="goDetail">
<text class="detail-text">{{ value.detailBtnText || '查看完整名片' }}</text>
</view>
</view>
<view v-else class="card-empty" :style="boxStyle">
<text class="empty-tip" :style="infoStyle">{{ emptyTip }}</text>
</view>
<diy-card-mini
:card="card"
:config="miniConfig"
:loading="loading"
empty-tip="商家还未选择名片"
@act="doAct"
@detail="goDetail"
></diy-card-mini>
</view>
</template>
<script>
// 数字名片 · 精选名片(装修组件): 单张名片的原生迷你卡
// 数据口径: /card/api/diy/card?card_id=.. 公开摘要(与 wap 公开页同源)
import DiyMinx from './minx.js';
import golbalConfig from '@/common/js/golbalConfig.js';
// 数字名片 · 精选名片(装修组件)
// 视觉基件 diy-card-mini(封面/渐变/Logo/主题色/折叠/芯片/二维码), 本文件只管数据
export default {
name: 'diy-card-show',
props: {
value: { type: Object }
},
mixins: [DiyMinx, golbalConfig],
data() {
return {
card: null
card: null,
loading: false,
};
},
computed: {
showRows() {
const flag = this.getDiyToggle(['showRows']);
return flag === undefined ? true : flag;
},
showQuick() {
const flag = this.getDiyToggle(['showQuick']);
return flag === undefined ? true : flag;
},
showDetailBtn() {
const flag = this.getDiyToggle(['showDetailBtn']);
return flag === undefined ? true : flag;
},
emptyTip() {
return String((this.value || {}).cardId || '').trim() === '' ? '商家还未选择名片' : '名片不存在或已删除';
},
subLine() {
const c = this.card || {};
return [c.job_title, c.company].filter(Boolean).join(' · ');
},
boxStyle() {
miniConfig() {
const v = this.value || {};
return this.$util.objToStyle({
background: v.bgColor || '#FFFFFF',
borderRadius: this.px2rpx(v.borderRadius || 16) + 'rpx',
padding: this.px2rpx(v.padding || 16) + 'rpx'
});
return {
bgColor: v.bgColor,
nameColor: v.nameColor,
infoColor: v.infoColor,
labelColor: v.labelColor,
borderRadius: v.borderRadius,
padding: v.padding,
showQuick: v.showQuick,
showDetailBtn: v.showDetailBtn,
detailBtnText: v.detailBtnText,
// 2.0 新增视觉开关
useCover: v.useCover,
showGradient: v.showGradient,
showLogo: v.showLogo,
showShadow: v.showShadow,
showRows: v.showRows,
maxRows: v.maxRows,
showQr: v.showQr,
qrTip: v.qrTip,
};
},
avatarStyle() {
return this.$util.objToStyle({ background: (this.card || {}).theme_color || '#2F6BFF' });
},
nameStyle() {
return this.$util.objToStyle({ color: (this.value || {}).nameColor || '#1F2329' });
},
infoStyle() {
return this.$util.objToStyle({ color: (this.value || {}).infoColor || '#888888' });
},
labelStyle() {
return this.$util.objToStyle({ color: (this.value || {}).labelColor || '#999999' });
},
actStyle() {
const theme = (this.card || {}).theme_color || '#2F6BFF';
return this.$util.objToStyle({ borderColor: theme });
},
actTextStyle() {
return this.$util.objToStyle({ color: (this.card || {}).theme_color || '#2F6BFF' });
},
detailStyle() {
return this.$util.objToStyle({ background: (this.card || {}).theme_color || '#2F6BFF' });
}
},
watch: {
'value.cardId'() {
this.fetchData();
}
},
},
mounted() {
this.fetchData();
},
methods: {
px2rpx(v) {
const n = Number(v);
return isNaN(n) ? 0 : Math.round(n * 2);
},
fetchData() {
const cardId = String((this.value || {}).cardId || '').trim();
if (cardId === '') {
this.card = null;
return;
}
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/card',
data: { card_id: cardId },
success: res => {
if (res.code == 0 && res.data && res.data.card) {
this.card = res.data.card;
} else {
this.card = null;
}
}
this.loading = false;
this.card = (res.code == 0 && res.data && res.data.card) ? res.data.card : null;
},
fail: () => {
this.loading = false;
this.card = null;
},
});
},
doAct(act) {
@@ -142,7 +86,7 @@ export default {
} else if (act.type === 'wx') {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' })
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
});
} else if (act.type === 'mail') {
// #ifdef H5
@@ -151,151 +95,26 @@ export default {
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '邮箱已复制', icon: 'none' })
success: () => uni.showToast({ title: '邮箱已复制', icon: 'none' }),
});
// #endif
} else {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '已复制', icon: 'none' })
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
});
}
},
goDetail() {
if (!this.card || !this.card.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: this.card.card_id });
}
}
goDetail(card) {
if (!card || !card.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
},
},
};
</script>
<style lang="scss" scoped>
.diy-card-show {
width: 100%;
.card-box {
width: 100%;
box-sizing: border-box;
.card-head {
display: flex;
align-items: center;
.head-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: 44rpx;
font-weight: 700;
color: #ffffff;
}
}
.head-info {
flex: 1;
min-width: 0;
margin-left: 24rpx;
display: flex;
flex-direction: column;
.head-name {
font-size: 34rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head-sub {
font-size: 24rpx;
margin-top: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.card-rows {
margin-top: 24rpx;
padding-top: 20rpx;
border-top: 2rpx solid #f0f0f0;
.row-line {
display: flex;
line-height: 1.9;
.row-label {
flex-shrink: 0;
font-size: 24rpx;
margin-right: 20rpx;
}
.row-value {
flex: 1;
font-size: 24rpx;
word-break: break-all;
}
}
}
.card-acts {
margin-top: 24rpx;
display: flex;
flex-wrap: wrap;
.act-btn {
border: 2rpx solid;
border-radius: 999rpx;
padding: 10rpx 28rpx;
margin: 0 16rpx 16rpx 0;
.act-text {
font-size: 24rpx;
font-weight: 500;
}
}
}
.detail-btn {
margin-top: 12rpx;
border-radius: 999rpx;
padding: 16rpx 0;
display: flex;
align-items: center;
justify-content: center;
.detail-text {
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
}
}
.card-empty {
width: 100%;
box-sizing: border-box;
.empty-tip {
display: block;
text-align: center;
padding: 30rpx 0;
font-size: 24rpx;
}
}
}
</style>