feat(diy-card): 4.0 头像开关 + 二维码四档外观/自定义尺寸/微调位移/提示开关

- common.js: QR_STYLES 四档枚举 + normalizeQrStyle(未知回落 card) +
  resolveQrSize(自定义 px×2>档位自动 rpx) + qrTransform(位移经
  translate, WXSS 不支持 calc)
- mini.vue 基件: 头像可隐藏(默认显示, 兼容历史配置); qrBlockStyle
  统一四档外观(plain 原图/card 白卡/theme 主题色/gradient 渐变,
  多彩只装饰外围衬底, 码黑白保可扫); cornerClearStyle 按实际尺寸
  反算让位(自定义大尺寸/关 tip 自动跟随); 扫码提示可关
- show/self: 6 新字段透传; ab-visual 测试 corner 断言改锚动态让位
- 新增 qr-style 契约测试 20 项(纯函数真值 + 基件 + 透传 + 两个
  design.html 面板控件绑定与预览消费 + 反例), 9 项变异验证全抓住
- 前端四测试文件共 66 项全绿; E2E 143 项; 三回归 79/41/56 全绿
This commit is contained in:
2026-10-21 12:28:18 +08:00
parent 8bdd9f1291
commit 805bbb9598
6 changed files with 352 additions and 38 deletions

View File

@@ -166,3 +166,29 @@ export function normalizeQrPosition(v) {
}
/** 各档位的二维码边长(rpx): 头部右侧紧凑, 浮层带衬底, 行下/底部大图便于扫 */
export const QR_SIZES = { rows: 180, head: 120, corner: 120, bottom: 180 };
/**
* 二维码外观样式(四档): plain 原图 / card 白卡描边(默认) / theme 主题色底 /
* gradient 渐变描边(码芯白底衬底, 保可扫)
* 多彩体现在装饰层(主题色/渐变), 码本身保持黑白 —— 可扫性优先
*/
export const QR_STYLES = ['plain', 'card', 'theme', 'gradient'];
export function normalizeQrStyle(v) {
const s = String(v || '').trim();
return QR_STYLES.indexOf(s) >= 0 ? s : 'card';
}
/** 二维码边长: 自定义 > 0 时用自定义(px), 否则按档位自动(rpx) */
export function resolveQrSize(position, custom) {
const n = Number(custom);
if (!isNaN(n) && n > 0) return n * 2;
return QR_SIZES[position] || QR_SIZES.rows;
}
/** 微调位移: 任一轴非零才输出 transform(WXSS 支持 translate; 不支持 calc) */
export function qrTransform(offsetX, offsetY) {
const x = Number(offsetX) || 0;
const y = Number(offsetY) || 0;
if (x === 0 && y === 0) return '';
return 'translate(' + (x * 2) + 'rpx, ' + (y * 2) + 'rpx)';
}

View File

@@ -39,14 +39,14 @@
<!-- Logo(可选, 右上; corner 档二维码占同一位, 自动避让) -->
<image v-if="showLogo && card.logo" class="mini-logo" :src="$util.img(card.logo)" mode="aspectFit" />
<!-- 二维码·右上角浮层(corner 档: 白底衬底保可扫, 资料行自动让位) -->
<view v-if="showQrCorner" class="mini-qr mini-qr-corner">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<!-- 二维码·右上角浮层(corner 档: 外观由 qrStyle 决定, 资料行自动让位) -->
<view v-if="showQrCorner" class="mini-qr mini-qr-corner" :style="qrBlockStyle">
<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">
<view class="mini-avatar" :style="avatarStyle">
<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>
@@ -59,14 +59,14 @@
<text v-if="subLine" class="mini-sub" :style="subTextStyle">{{ subLine }}</text>
</view>
<!-- 二维码·头部右侧(head 档: 头像|信息|二维码 三段 flex, 零遮挡) -->
<view v-if="showQrHead" class="mini-qr mini-qr-head">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<view v-if="showQrHead" class="mini-qr mini-qr-head" :style="qrBlockStyle">
<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">
<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>
@@ -79,13 +79,13 @@
</view>
<!-- 二维码·资料行下方(rows 档, 默认) -->
<view v-if="showQrRows" class="mini-qr">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<view v-if="showQrRows" class="mini-qr" :style="qrBlockStyle">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<!-- 快捷操作(芯片, 最小触达 72rpx; 首个为主动作) -->
<view v-if="showActs && acts.length" class="mini-acts">
<view v-if="showActs && acts.length" class="mini-acts" :style="cornerClearStyle">
<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>
@@ -93,9 +93,9 @@
</view>
<!-- 二维码·主按钮上方(bottom 档) -->
<view v-if="showQrBottom" class="mini-qr">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<view v-if="showQrBottom" class="mini-qr" :style="qrBlockStyle">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<!-- 查看完整名片 -->
@@ -111,7 +111,7 @@
// 消费名片自身设计资产: cover 封面 / logo / theme_color / 渐变末色与角度 / card_title
import {
CardTokens, px2rpx, flagOn, num, initialOf, avatarBg, themeGradient, visibleRows,
rowIconFor, rowActOf, normalizeQrPosition, QR_SIZES,
rowIconFor, rowActOf, normalizeQrPosition, normalizeQrStyle, resolveQrSize, qrTransform, QR_SIZES,
} from './diy-card-common.js';
export default {
@@ -163,13 +163,61 @@ export default {
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'; },
qrImgStyle() {
const s = QR_SIZES[this.qrPos];
return this.$util.objToStyle({ width: s + 'rpx', height: s + 'rpx' });
/** 头像开关(历史配置无此字段时默认显示) */
showAvatarBlock() {
return flagOn(this.c.showAvatar, true);
},
/**
* 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') {
style.background = themeGradient(this.card);
style.borderRadius = '16rpx';
style.padding = '6rpx';
}
return this.$util.objToStyle(style);
},
showDetailBtn() {
return flagOn(this.c.showDetailBtn, true);
@@ -503,9 +551,10 @@ export default {
flex-direction: column;
align-items: center;
/* 块尺寸由 qrBlockStyle 内联给出(自定义/档位), 图片填满内容盒 */
.qr-img {
width: 180rpx;
height: 180rpx;
width: 100%;
height: 100%;
display: block;
}
@@ -527,29 +576,21 @@ export default {
}
}
/* corner 档: 右上角浮层, 白底衬底保证封面/纯色上都可扫 */
/* corner 档: 右上角浮层(外观衬底由 qrStyle 内联决定, 此处只定位于层次) */
&.mini-qr-corner {
position: absolute;
top: 20rpx;
right: 20rpx;
z-index: 3;
margin-top: 0;
background: #ffffff;
border-radius: 12rpx;
padding: 10rpx;
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.12);
}
}
/* corner 档: 浮层二维码(120rpx+衬底+tip)下探约 202rpx, 资料行/操作区让位防遮挡 */
/* corner 档: 头部右侧留出浮层宽度: WXSS 不支持 calc(), 用固定 padding-right
(浮层 120rpx + 右偏移 20rpx + 呼吸 20rpx ≈ 160, 取 180 富余);
资料行/操作区让位改由 cornerClearStyle 按实际尺寸动态算 */
&.qr-at-corner {
.mini-rows,
.mini-acts {
margin-top: 108rpx;
}
/* 头部右侧留出浮层宽度: WXSS 不支持 calc(), 用固定 padding-right
(浮层 120rpx + 右偏移 20rpx + 呼吸 20rpx ≈ 160, 取 180 富余) */
.mini-head {
padding-right: 180rpx;
}

View File

@@ -90,6 +90,13 @@ export default {
showRowIcons: v.showRowIcons,
rowTap: v.rowTap,
qrPosition: v.qrPosition,
// 3.1 头像开关 + 二维码样式/微调
showAvatar: v.showAvatar,
qrStyle: v.qrStyle,
qrSize: v.qrSize,
qrOffsetX: v.qrOffsetX,
qrOffsetY: v.qrOffsetY,
qrTipShow: v.qrTipShow,
};
},
boxStyle() {

View File

@@ -52,6 +52,13 @@ export default {
showRowIcons: v.showRowIcons,
rowTap: v.rowTap,
qrPosition: v.qrPosition,
// 3.1 头像开关 + 二维码样式/微调
showAvatar: v.showAvatar,
qrStyle: v.qrStyle,
qrSize: v.qrSize,
qrOffsetX: v.qrOffsetX,
qrOffsetY: v.qrOffsetY,
qrTipShow: v.qrTipShow,
};
},
},