Merge remote-tracking branch 'origin/feat/camcard' into feat/camcard

# Conflicts:
#	components-diy/diy-card-mini.vue
#	components-diy/diy-card-show.vue
#	tests/diy-card-ab-visual.test.js
#	tests/diy-card-qr-style.test.js
This commit is contained in:
2026-10-24 10:56:20 +08:00
12 changed files with 2077 additions and 58 deletions

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,7 @@
<!-- 按钮(可选; 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>
<text v-if="buttonStyle === 'link'" class="btn-arrow" :style="btnArrowStyle">›</text>
</view>
</view>
</template>
@@ -103,11 +103,13 @@ export default {
titleStyle() {
return this.$util.objToStyle({
color: this.v.textColor || (this.isMinimal ? CardTokens.color.text : '#FFFFFF'),
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
});
},
subStyle() {
return this.$util.objToStyle({
color: this.v.textColor || (this.isMinimal ? CardTokens.color.textSub : '#FFFFFF'),
fontSize: px2rpx(num(this.v.subSize, 12)) + 'rpx',
opacity: this.isMinimal ? 1 : 0.85,
});
},
@@ -135,13 +137,24 @@ export default {
},
btnTextStyle() {
const v = this.v;
const fontSize = px2rpx(num(this.v.btnSize, 12)) + 'rpx';
if (this.buttonStyle === 'ghost') {
return this.$util.objToStyle({ color: v.accentColor || '#FFEB3B' });
return this.$util.objToStyle({ color: v.accentColor || '#FFEB3B', fontSize: fontSize });
}
if (this.buttonStyle === 'link') {
return this.$util.objToStyle({ color: v.textColor || CardTokens.color.primary });
return this.$util.objToStyle({ color: v.textColor || CardTokens.color.primary, fontSize: fontSize });
}
return this.$util.objToStyle({ color: '#333333' });
return this.$util.objToStyle({ color: '#333333', fontSize: fontSize });
},
/**
* 文字箭头按钮的箭头: 仅 link 档渲染, 颜色与该档按钮文字一致;
* 字号比按钮文字略大一档(默认 14px = 28rpx, 与历史渲染一致)
*/
btnArrowStyle() {
return this.$util.objToStyle({
color: this.v.textColor || CardTokens.color.primary,
fontSize: px2rpx(num(this.v.btnSize, 12) + 2) + 'rpx',
});
},
},
methods: {

View File

@@ -27,8 +27,8 @@
<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 class="scroll-name" :style="scrollNameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="infoLine(item)" class="scroll-meta" :style="scrollMetaStyle">{{ infoLine(item) }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
</view>
</scroll-view>
@@ -46,10 +46,10 @@
</view>
<view class="item-info">
<view class="name-row">
<text class="item-name" :style="nameStyle">{{ item.name || '未命名名片' }}</text>
<text class="item-name" :style="gridNameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
</view>
<text v-if="infoLine(item)" class="item-meta" :style="infoStyle">{{ infoLine(item) }}</text>
<text v-if="infoLine(item)" class="item-meta" :style="metaStyle">{{ infoLine(item) }}</text>
</view>
<text v-if="showArrow" class="item-arrow" :style="infoStyle">›</text>
</view>
@@ -129,11 +129,40 @@ export default {
});
},
titleStyle() {
return this.$util.objToStyle({ color: this.v.titleColor || CardTokens.color.text });
return this.$util.objToStyle({
color: this.v.titleColor || CardTokens.color.text,
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
});
},
nameStyle() {
return this.$util.objToStyle({ color: this.v.nameColor || CardTokens.color.text });
/** 网格/列表卡片姓名: 字号可配(默认 14px = 28rpx) */
gridNameStyle() {
return this.$util.objToStyle({
color: this.v.nameColor || CardTokens.color.text,
fontSize: px2rpx(num(this.v.nameSize, 14)) + 'rpx',
});
},
/** 横滑竖卡姓名: 比网格小一档(默认 13px = 26rpx, 与历史渲染一致) */
scrollNameStyle() {
return this.$util.objToStyle({
color: this.v.nameColor || CardTokens.color.text,
fontSize: px2rpx(num(this.v.nameSize, 14) - 1) + 'rpx',
});
},
/** 网格/列表卡片信息行: 字号可配(默认 11px = 22rpx) */
metaStyle() {
return this.$util.objToStyle({
color: this.v.infoColor || CardTokens.color.textSub,
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
});
},
/** 横滑竖卡信息行: 比网格小一档(默认 10px = 20rpx, 与历史渲染一致) */
scrollMetaStyle() {
return this.$util.objToStyle({
color: this.v.infoColor || CardTokens.color.textSub,
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
});
},
/** infoColor 纯色(箭头/空态用, 不带字号) */
infoStyle() {
return this.$util.objToStyle({ color: this.v.infoColor || CardTokens.color.textSub });
},
@@ -141,10 +170,15 @@ export default {
return this.$util.objToStyle({
color: CardTokens.color.primary,
background: 'rgba(47,107,255,0.08)',
// 人数徽标比信息行小一档(默认 10px = 20rpx, 与历史渲染一致)
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
});
},
moreStyle() {
return this.$util.objToStyle({ color: CardTokens.color.textLabel });
return this.$util.objToStyle({
color: CardTokens.color.textLabel,
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
});
},
itemStyle() {
const gap = num(this.v.gap, 10);

View File

@@ -17,7 +17,7 @@
<!-- 空态: 未拿到名片 -->
<view v-else-if="!card" class="mini-empty" :style="wrapStyle">
<text class="empty-icon">💳</text>
<text class="empty-tip" :style="subTextStyle">{{ emptyTip || '暂无名片数据' }}</text>
<text class="empty-tip" :style="emptyTipStyle">{{ 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>
@@ -43,11 +43,37 @@
<!-- 二维码·右上角浮层(corner 档: 外观由 qrStyle 决定, 资料行自动让位; 点击预览名片) -->
<view v-if="showQrCorner" class="mini-qr mini-qr-corner" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(card.qr_url)" mode="aspectFit" />
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<view class="mini-head" :style="cornerHeadStyle">
<!-- 头部·主题渐变整块档(theme): 渐变通铺卡边 + 装饰圆, plain 档不渲染 -->
<view v-if="headTheme" class="mini-headtheme" :style="headThemeStyle">
<view class="headtheme-decor headtheme-decor--big"></view>
<view class="headtheme-decor headtheme-decor--small"></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="cardTitleStyle">{{ 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">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
</view>
</view>
<!-- 头部·白卡档(plain, 历史默认) -->
<view v-if="!headTheme" 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>
@@ -57,23 +83,59 @@
<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="card.card_title" class="mini-card-title" :style="cardTitleStyle">{{ 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" :style="qrImgStyle" :src="$util.img(card.qr_url)" mode="aspectFit" />
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
</view>
<!-- 快捷操作·资料区上方档(2.18 actsPosition=above): below 时由下方同构块渲染 -->
<view v-if="actsAbove && showActs && acts.length && actsGrid" class="mini-acts-grid" :style="[actsBlockStyle, cornerClearStyle]">
<view v-for="(act, i) in acts" :key="'ta' + i" class="act-grid-item" :style="{ width: gridItemWidth }" hover-class="btn-press" @tap.stop="onAct(act)">
<view class="act-grid-icon" :style="actGridIconStyle(act)">
<!-- 自定义图标图片(2.18 媒体库, 优先) / SVG 线稿(2.18.1 与 wap detail 同源) / 首字兜底 -->
<image v-if="actImgOf(act)" class="act-grid-img" :src="$util.img(actImgOf(act))" mode="aspectFit" />
<view v-else-if="actLibOf(act)" class="act-grid-svg" :style="actLibStyle(act)"></view>
<view v-else-if="actSvgOf(act)" class="act-grid-svg" :style="actSvgStyle(act)"></view>
<text v-else class="act-grid-initial" :style="actGridGlyphStyle(act)">{{ actInitialOf(act) }}</text>
</view>
<text class="act-grid-label" :style="actGridLabelStyle(act)">{{ act.label }}</text>
</view>
</view>
<view v-if="actsAbove && showActs && acts.length && !actsGrid" class="mini-acts" :style="[actsBlockStyle, cornerClearStyle]">
<view v-for="(act, i) in acts" :key="'ta' + 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="showRowsBlock && shownRows.length" class="mini-rows" :style="cornerClearStyle">
<view v-if="showRowsBlock && shownRows.length" class="mini-rows" :style="rowsClearStyle">
<!-- 资料区标题(2.17 可选): 主题色竖条 + 标题 -->
<view v-if="rowsTitleShow" class="mini-rows-title">
<view class="rows-title-bar" :style="rowsTitleBarStyle"></view>
<text class="rows-title-text" :style="rowsTitleStyle">{{ rowsTitle }}</text>
</view>
<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>
<!-- CSS 描边图标档(2.17.1): 邮箱/网站无 iconfont 字形, 纯 view 绘制 -->
<view v-if="rowCssIcon(row)" class="row-css-wrap">
<view v-if="rowCssIcon(row) === 'mail'" class="css-ico-mail css-ico-mail--row" :style="rowCssBorderStyle(row)">
<view class="css-ico-mail-flap" :style="rowCssBorderStyle(row)"></view>
</view>
<view v-else class="css-ico-web css-ico-web--row" :style="rowCssBorderStyle(row)">
<view class="css-ico-web-h" :style="rowCssFillStyle(row)"></view>
<view class="css-ico-web-v" :style="rowCssBorderStyle(row)"></view>
</view>
</view>
<text v-else-if="rowIcon(row)" class="row-icon iconfont" :class="rowIcon(row)" :style="labelStyle"></text>
<text v-else class="row-label" :style="rowLabelStyle">{{ row.label }}</text>
<text class="row-value" :style="valueStyle">{{ row.value }}</text>
<text v-if="showCopyBtn && row.value" class="row-copy" :style="copyBtnStyle" @tap.stop="onCopyRow(row)">复制</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>
@@ -82,13 +144,26 @@
<!-- 二维码·资料行下方(rows 档, 默认; 点击预览名片); mini-qr-center: 块级居中 -->
<view v-if="showQrRows" class="mini-qr mini-qr-center" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(card.qr_url)" mode="aspectFit" />
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" 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">
<!-- 快捷操作·图标宫格档(grid): 彩色浅底图标 + 下方文字 -->
<view v-if="!actsAbove && showActs && acts.length && actsGrid" class="mini-acts-grid" :style="[actsBlockStyle, cornerClearStyle]">
<view v-for="(act, i) in acts" :key="i" class="act-grid-item" :style="{ width: gridItemWidth }" hover-class="btn-press" @tap.stop="onAct(act)">
<view class="act-grid-icon" :style="actGridIconStyle(act)">
<!-- 自定义图标图片(2.18 媒体库, 优先) / SVG 线稿(2.18.1 与 wap detail 同源) / 首字兜底 -->
<image v-if="actImgOf(act)" class="act-grid-img" :src="$util.img(actImgOf(act))" mode="aspectFit" />
<view v-else-if="actLibOf(act)" class="act-grid-svg" :style="actLibStyle(act)"></view>
<view v-else-if="actSvgOf(act)" class="act-grid-svg" :style="actSvgStyle(act)"></view>
<text v-else class="act-grid-initial" :style="actGridGlyphStyle(act)">{{ actInitialOf(act) }}</text>
</view>
<text class="act-grid-label" :style="actGridLabelStyle(act)">{{ act.label }}</text>
</view>
</view>
<!-- 快捷操作·胶囊芯片档(chips, 历史默认; 最小触达 72rpx; 首个为主动作) -->
<view v-if="!actsAbove && showActs && acts.length && !actsGrid" class="mini-acts" :style="[actsBlockStyle, 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>
@@ -158,7 +233,12 @@
// 消费名片自身设计资产: cover 封面 / logo / theme_color / 渐变末色与角度 / card_title
import {
CardTokens, px2rpx, flagOn, num, initialOf, avatarBg, themeGradient, darkenColor, visibleRows,
rowIconFor, rowActOf, normalizeQrPosition, normalizeQrStyle, resolveQrSize, qrTransform, QR_SIZES,
rowIconFor, rowCssIconFor, rowActOf, normalizeQrPosition, normalizeQrStyle, resolveQrSize, qrTransform, QR_SIZES,
normalizeHeadStyle, normalizeActsLayout, normalizeActGridColorMode, normalizeActsPosition, actIconCfgOf,
WAP_LINE_ICONS, svgLineIconBg,
ACT_GRID_PRESETS, ACT_GRID_PRESET_FALLBACK, hexToRgba,
// 2.19: 显隐/文案/自定义动作归一化 + 素材图标库(与后端 CardIconLibrary 同源)
normalizeActVis, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg,
} from './diy-card-common.js';
export default {
@@ -203,9 +283,17 @@ export default {
// 资料行总开关(面板「显示资料行」); 历史配置无此字段时默认显示, 兼容 1.0
return flagOn(this.c.showRows, true);
},
/** 二维码总条件: 开关 + 名片有二维码地址 */
/**
* 二维码总条件: 开关 + 有码源(自定义图 qrImage 优先, 回落名片码 qr_url)
*/
showQrBase() {
return flagOn(this.c.showQr, false) && !!(this.card && this.card.qr_url);
return flagOn(this.c.showQr, false) && !!this.qrSrc;
},
/** 二维码码源(2.18): 用户自定义图片(媒体库)优先, 空=名片自动生成的码 */
qrSrc() {
const custom = String(this.c.qrImage || '').trim();
if (custom) return custom;
return (this.card && this.card.qr_url) || '';
},
qrPos() {
return normalizeQrPosition(this.c.qrPosition);
@@ -237,7 +325,8 @@ export default {
},
/**
* corner 档资料行/操作区让位量: 按二维码实际占高反算
* (浮层 top 20 + 块高[尺寸+样式衬底+tip] - 头部 104 + 间隙 10), 防遮挡;
* (浮层 top 20 + 块高[尺寸+样式衬底+tip] - 头部实际高 + 间隙 10), 防遮挡;
* 头部实际高: plain=104 / theme 档=32+104+28=164(上下 padding 增高, 2.17);
* 自定义大尺寸/关 tip 时自动跟随(不用 calc, 纯数值)
*/
cornerClearStyle() {
@@ -246,7 +335,8 @@ export default {
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' };
const headHeight = this.headTheme ? 164 : 104;
return { marginTop: Math.max(24, total - headHeight + 10) + 'rpx' };
},
/**
* 二维码容器样式: 只管尺寸(自定义>档位自动) + 微调位移(transform, WXSS 支持)。
@@ -305,6 +395,37 @@ export default {
borderRadius: px2rpx(CardTokens.radius.pill) + 'rpx',
});
},
/** 头部形态(2.17): theme=渐变整块头部(历史配置无此字段时为 plain, 兼容 1.0) */
headTheme() {
return normalizeHeadStyle(this.c.headStyle) === 'theme';
},
/**
* theme 档头部块: 渐变通铺卡边(负 margin 抵消 wrapStyle 的 padding, WXSS 不用 calc)
* 内部左右 padding = 卡片原 padding, 保证头像与下方资料行左缘对齐
*/
headThemeStyle() {
const pad = px2rpx(num(this.c.padding, CardTokens.space.lg));
return this.$util.objToStyle({
background: themeGradient(this.card),
margin: '-' + pad + 'rpx -' + pad + 'rpx 0',
padding: '32rpx ' + pad + 'rpx 28rpx',
});
},
/** 资料区标题(2.17): 默认关(历史配置无此字段时不出标题) */
rowsTitleShow() {
return flagOn(this.c.showRowsTitle, false);
},
rowsTitle() {
return String(this.c.rowsTitle || '联系资料');
},
/** 快捷操作布局(2.17): 默认 chips 胶囊(历史配置无此字段时不变) */
actsGrid() {
return normalizeActsLayout(this.c.actsLayout) === 'grid';
},
/** 复制按钮(2.17): 默认关; 手机等资料行右侧浅底小按钮, 点击复制原文 */
showCopyBtn() {
return flagOn(this.c.showCopyBtn, false);
},
qrTip() {
return this.c.qrTip || '';
},
@@ -327,8 +448,56 @@ export default {
hiddenCount() {
return this.rowsInfo.rest.length;
},
/**
* 快捷操作(2.19 重构): 服务端 acts(资料驱动) + 组件配置合成
* 1. actVis 显隐过滤(缺省全显, 历史形态零变化)
* 2. actLabels 文案覆盖(空/缺 = 服务端 label)
* 3. customActs 自定义动作尾接(vis=0 隐藏)
* 每项合并 cfgImg/cfgKey/cfgColor/cfgBg 四字段 —— 图标渲染统一走
* 「媒体库图 > 素材库图标 > SVG 线稿 > 首字」优先级, 免去方法内重复取配置
*/
acts() {
return this.card && Array.isArray(this.card.acts) ? this.card.acts : [];
const raw = this.card && Array.isArray(this.card.acts) ? this.card.acts : [];
const vis = normalizeActVis(this.c.actVis);
const labels = normalizeActLabels(this.c.actLabels);
const icons = this.c.actIcons && typeof this.c.actIcons === 'object' ? this.c.actIcons : {};
const order = normalizeActOrder(this.c.actOrder);
const merge = (a) => {
const t = String(a.type);
const cfg = icons[t] && typeof icons[t] === 'object' ? icons[t] : {};
return Object.assign({}, a, {
label: labels[t] || a.label,
cfgImg: String(cfg.img || '').trim(),
cfgKey: String(cfg.iconKey || '').trim(),
cfgColor: String(cfg.color || '').trim(),
cfgBg: String(cfg.bg || '').trim(),
});
};
// 2.19.1: 按 actOrder 次序输出默认动作; 后端 quickActs 扩展类型(order 外)尾接兜底
const byType = {};
raw.forEach(a => {
if (a && a.type) byType[String(a.type)] = a;
});
const out = [];
order.forEach(t => {
if (byType[t] && vis[t] !== 0) out.push(merge(byType[t]));
});
raw.forEach(a => {
if (!a || !a.type) return;
const t = String(a.type);
if (order.indexOf(t) >= 0 || vis[t] === 0) return;
out.push(merge(a));
});
normalizeCustomActs(this.c.customActs).forEach(ca => {
if (ca.vis === 0) return;
out.push(Object.assign({}, ca, {
cfgImg: ca.img,
cfgKey: ca.iconKey,
cfgColor: ca.color,
cfgBg: ca.bg,
}));
});
return out;
},
isEnterprise() {
return this.card && this.card.card_type === 'enterprise';
@@ -356,16 +525,73 @@ export default {
return this.$util.objToStyle({ background: avatarBg(this.card) });
},
nameStyle() {
return this.$util.objToStyle({ color: this.c.nameColor || CardTokens.color.text });
// theme 渐变头部档: 深底上默认白字(headNameColor 可覆盖); plain 档沿用历史配色
const autoColor = this.headTheme ? (this.c.headNameColor || '#FFFFFF') : (this.c.nameColor || CardTokens.color.text);
return this.$util.objToStyle({
color: autoColor,
fontSize: px2rpx(num(this.c.nameSize, 17)) + 'rpx',
});
},
/** 名片头衔(设计器 card_title): 默认 13px = 26rpx, 与副行 24rpx 区分 */
cardTitleStyle() {
const autoColor = this.headTheme ? (this.c.headSubColor || 'rgba(255,255,255,0.9)') : (this.c.infoColor || CardTokens.color.textSub);
return this.$util.objToStyle({
color: autoColor,
fontSize: px2rpx(num(this.c.cardTitleSize, 13)) + 'rpx',
});
},
/** 副行(公司/职位)与空态提示共用: 默认 12px = 24rpx */
subTextStyle() {
return this.$util.objToStyle({ color: this.c.infoColor || CardTokens.color.textSub });
// theme 档副行默认白 85% 透明度(渐变深底可读); plain 档沿用历史配色
const autoColor = this.headTheme ? (this.c.headSubColor || 'rgba(255,255,255,0.85)') : (this.c.infoColor || CardTokens.color.textSub);
return this.$util.objToStyle({
color: autoColor,
fontSize: px2rpx(num(this.c.subSize, 12)) + 'rpx',
});
},
/** labelColor 纯色(资料行图标/二维码提示用, 不带字号) */
labelStyle() {
return this.$util.objToStyle({ color: this.c.labelColor || CardTokens.color.textLabel });
},
/** 资料行文字标签: 默认 12px = 24rpx */
rowLabelStyle() {
return this.$util.objToStyle({
color: this.c.labelColor || CardTokens.color.textLabel,
fontSize: px2rpx(num(this.c.labelSize, 12)) + 'rpx',
});
},
valueStyle() {
return this.$util.objToStyle({ color: this.c.infoColor || CardTokens.color.textSub });
return this.$util.objToStyle({
color: this.c.infoColor || CardTokens.color.textSub,
fontSize: px2rpx(num(this.c.valueSize, 12)) + 'rpx',
});
},
/** 空态提示: 固定历史配色(不随 theme 头部变白 —— 空态无渐变头) */
emptyTipStyle() {
return this.$util.objToStyle({
color: this.c.infoColor || CardTokens.color.textSub,
fontSize: px2rpx(num(this.c.subSize, 12)) + 'rpx',
});
},
/** 资料区标题(2.17): 主题色竖条 + 主文字色标题 */
rowsTitleBarStyle() {
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({ background: theme });
},
rowsTitleStyle() {
return this.$util.objToStyle({
color: this.c.nameColor || CardTokens.color.text,
fontSize: px2rpx(14) + 'rpx',
fontWeight: '600',
});
},
/** 复制按钮: 主题浅底 + 主题色字(hexToRgba 动态派生, WXSS 无 color-mix) */
copyBtnStyle() {
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({
background: hexToRgba(theme, 0.12) || '#F2F3F5',
color: theme,
});
},
badgeStyle() {
// 企业徽标固定红底(参考图 2026-10-22 口径), 与头像底色解耦
@@ -380,12 +606,81 @@ export default {
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({
background: '#FFFFFF',
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',
fontSize: px2rpx(num(this.c.actSize, 12)) + 'rpx',
});
},
/** 首动作(索引 0)实心底: 主题色 + 胶囊尺寸(与 actStyle/actGhostStyle 同口径) */
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',
});
},
actGhostStyle() {
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
return this.$util.objToStyle({
background: 'transparent',
border: '2rpx solid ' + theme,
});
},
actTextStyle() {
return this.$util.objToStyle({ color: (this.card && this.card.theme_color) || CardTokens.color.primary });
},
actGhostTextStyle() {
return this.$util.objToStyle({
color: (this.card && this.card.theme_color) || CardTokens.color.primary,
fontSize: px2rpx(num(this.c.actSize, 12)) + 'rpx',
});
},
/** 发送名片按钮文字: 默认 13px = 26rpx */
detailTextStyle() {
return this.$util.objToStyle({
color: '#FFFFFF',
fontSize: px2rpx(num(this.c.btnSize, 13)) + 'rpx',
});
},
/** 宫格配色模式(2.17): preset 多彩预设 / theme 主题色派生 */
gridColorMode() {
return normalizeActGridColorMode(this.c.actGridColorMode);
},
/** 宫格列宽: 5 个动作一行(存通讯录入列后), 4 个及以下仍四列均分 */
gridItemWidth() {
return this.acts.length > 4 ? '20%' : '25%';
},
/** 快捷操作区位置(2.18): above=资料区上方(below 历史默认零变化) */
actsAbove() {
return normalizeActsPosition(this.c.actsPosition) === 'above';
},
/**
* 快捷操作区独立块样式(2.18): 背景/圆角/内边距, 全为默认(历史形态)时输出空串零变化
*/
actsBlockStyle() {
const c = this.c;
const bg = String(c.actsBlockBg || '').trim();
const radius = num(c.actsBlockRadius, 0);
const pad = num(c.actsBlockPad, 0);
const style = {};
if (bg) style.background = bg;
if (radius > 0) style.borderRadius = px2rpx(radius) + 'rpx';
if (pad > 0) style.padding = px2rpx(pad) + 'rpx';
return this.$util.objToStyle(style);
},
/** 资料行让位: 操作区在上方时由操作区让位, 资料行不再重复位移 */
rowsClearStyle() {
return this.actsAbove ? {} : this.cornerClearStyle;
},
},
detailBtnStyle() {
// 发送名片大按钮: 纯色主题实心(2026-10-22 参考图定稿, 不再用渐变);
// 高度 104rpx(用户反馈: 比触达标准 80rpx 加高一档, 参考图观感)
@@ -412,6 +707,20 @@ export default {
rowIcon(row) {
return flagOn(this.c.showRowIcons, true) ? rowIconFor(row && row.field) : '';
},
/** 资料行 CSS 描边图标(2.17.1): 邮箱=信封 / 网站=线框地球, 同受行图标开关 */
rowCssIcon(row) {
return flagOn(this.c.showRowIcons, true) ? rowCssIconFor(row && row.field) : '';
},
/** 资料行 CSS 图标颜色: 跟随 labelColor(与 iconfont 行图标同色口径) */
rowCssColor() {
return this.c.labelColor || CardTokens.color.textLabel;
},
rowCssBorderStyle() {
return this.$util.objToStyle({ borderColor: this.rowCssColor() });
},
rowCssFillStyle() {
return this.$util.objToStyle({ background: this.rowCssColor() });
},
/** 资料行是否可点: 行点击开关 + 服务端 href 派生得出动作 */
rowTapable(row) {
return flagOn(this.c.rowTap, true) && !!rowActOf(row);
@@ -423,7 +732,6 @@ export default {
onAct(act) {
this.$emit('act', act);
},
/* ---------- 分享名片抽屉(「发送名片」弹出; 版式/逻辑与精选名片 show 完全同款) ---------- */
openCardShare() {
this.showShare = true;
@@ -490,6 +798,78 @@ export default {
onSend() {
this.$emit('send', this.card);
},
/** 每按钮自定义图标图片(2.18 媒体库, 优先级最高; acts 已合并 cfgImg) */
actImgOf(act) {
return String((act && act.cfgImg) || '').trim() || String(actIconCfgOf(this.c.actIcons, act && act.type).img || '').trim();
},
/**
* 素材库图标(2.19): acts 已合并 cfgKey(iconKey), 库缺 key 返回空串回落线稿档
*/
actLibOf(act) {
return String((act && act.cfgKey) || '').trim();
},
/** 素材库图标背景(实时配色: color 空回落 actIconColor 四级口径) */
actLibStyle(act) {
return this.$util.objToStyle({ backgroundImage: libIconBg(this.actLibOf(act), this.actIconColor(act)) });
},
/** 宫格 SVG 线稿(2.18.1): 与 wap detail 同源, 素材库/媒体库优先时不再进入 */
actSvgOf(act) {
return !this.actImgOf(act) && !this.actLibOf(act) && !!WAP_LINE_ICONS[String((act && act.type) || '')];
},
/**
* 图标前景色: act 合并的每按钮 color > detail 同源固定色(wx 微信绿/save 通讯录橙)
* > theme 档主题色 > preset 档预设深字
*/
actIconColor(act) {
const custom = String((act && act.cfgColor) || '').trim();
if (custom) return custom;
const ico = WAP_LINE_ICONS[String((act && act.type) || '')];
if (ico && ico.color) return ico.color;
if (this.gridColorMode === 'theme') {
return (this.card && this.card.theme_color) || CardTokens.color.primary;
}
const preset = ACT_GRID_PRESETS[(act && act.type) || ''] || ACT_GRID_PRESET_FALLBACK;
return preset.fg;
},
/** SVG 线稿背景(data URI, 颜色内嵌) */
actSvgStyle(act) {
return this.$util.objToStyle({ backgroundImage: svgLineIconBg(act && act.type, this.actIconColor(act)) });
},
actInitialOf(act) {
return String((act && act.label) || '·').charAt(0);
},
/** 宫格配色(每按钮 bg 覆盖 / preset 多彩预设 / theme 主题渐变) —— 带参, 走 methods */
actGridIconStyle(act) {
// 每按钮自定义底色优先(2.18), 空回落 preset/theme 档(acts 已合并 cfgBg)
const cfgBg = String((act && act.cfgBg) || '').trim() || String(actIconCfgOf(this.c.actIcons, act && act.type).bg || '').trim();
if (cfgBg) return this.$util.objToStyle({ background: cfgBg });
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
if (this.gridColorMode === 'theme') {
// detail 同款浅主题渐变底(145deg, 12%→20% 主题色混白)
return this.$util.objToStyle({
background: 'linear-gradient(145deg, ' + (hexToRgba(theme, 0.12) || '#F2F3F5') + ', ' + (hexToRgba(theme, 0.2) || '#F2F3F5') + ')',
});
}
const preset = ACT_GRID_PRESETS[(act && act.type) || ''] || ACT_GRID_PRESET_FALLBACK;
return this.$util.objToStyle({ background: preset.bg });
},
/** 首字兜底字色(未知类型), 与每按钮 color/固定色同口径 */
actGridGlyphStyle(act) {
return this.$util.objToStyle({ color: this.actIconColor(act) });
},
actGridLabelStyle() {
return this.$util.objToStyle({
color: this.c.labelColor || CardTokens.color.textSub,
fontSize: px2rpx(num(this.c.actSize, 12)) + 'rpx',
});
},
/** 复制按钮: 复用 row-act 的 copy 分支(rowTap 关闭时仍可独立复制) */
onCopyRow(row) {
this.$emit('row-act', { type: 'copy', value: row.value });
},
onDetail() {
this.$emit('detail', this.card);
},
onEmptyAction() {
this.$emit('empty-action');
},
@@ -602,6 +982,123 @@ export default {
}
}
/* theme 渐变头部档: 白描边白底字头像更贴合深底(仅 theme 档, plain 零变化) */
.mini-headtheme & {
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
}
}
/* 头部·主题渐变整块档(2.17): 通铺卡边 + 装饰圆; 颜色固定白透明度, 任意主题色可见
圆角只给底部 —— 顶部贴卡边, 避免与卡片圆角叠加出双圆角 */
.mini-headtheme {
position: relative;
z-index: 2;
overflow: hidden;
border-radius: 0 0 16rpx 16rpx;
.headtheme-decor {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
pointer-events: none;
&--big {
width: 240rpx;
height: 240rpx;
right: -60rpx;
top: -80rpx;
}
&--small {
width: 120rpx;
height: 120rpx;
left: -40rpx;
bottom: -50rpx;
background: rgba(255, 255, 255, 0.08);
}
}
}
/* 资料区标题(2.17): 主题色竖条(尺寸内联) + 600 字重标题 */
.mini-rows-title {
display: flex;
align-items: center;
margin-bottom: 16rpx;
.rows-title-bar {
flex-shrink: 0;
width: 8rpx;
height: 28rpx;
border-radius: 4rpx;
margin-right: 12rpx;
}
}
/* 复制按钮(2.17): 浅底主题色小按钮(色内联), 不挤压资料值换行 */
.row-copy {
flex-shrink: 0;
margin-left: 16rpx;
padding: 4rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
line-height: 1.4;
}
/* 快捷操作·图标宫格档(2.17): 均分多列, 图标浅底块 88rpx
列宽由内联 gridItemWidth 动态给出(5 动作 20% / 4 及以下 25%), 此处为兜底 */
.mini-acts-grid {
position: relative;
z-index: 2;
margin-top: 24rpx;
display: flex;
flex-wrap: wrap;
.act-grid-item {
width: 25%;
box-sizing: border-box;
padding: 8rpx 0;
display: flex;
flex-direction: column;
align-items: center;
.act-grid-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
/* SVG 线稿图标(2.18.1): data URI 背景呈现, 与 wap detail 同源 */
.act-grid-svg {
width: 44rpx;
height: 44rpx;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
/* 自定义图标图片(2.18): 铺满图标块, 圆角继承 */
.act-grid-img {
width: 100%;
height: 100%;
display: block;
border-radius: inherit;
}
.act-grid-initial {
font-size: 36rpx;
font-weight: 600;
}
}
.act-grid-label {
margin-top: 12rpx;
line-height: 1.4;
}
}
}
.mini-head-info {
flex: 1;
min-width: 0;
@@ -658,6 +1155,46 @@ export default {
display: flex;
line-height: 2;
/* CSS 描边图标容器(2.17.1): 与 iconfont 图标列/文字标签列同宽 96rpx, 值列左缘对齐 */
.row-css-wrap {
flex-shrink: 0;
width: 96rpx;
display: flex;
align-items: center;
justify-content: center;
align-self: center;
}
/* 行内缩小版信封/地球(宫格同构图形, 边框色内联) */
.css-ico-mail--row {
width: 30rpx;
height: 22rpx;
border-width: 3rpx;
border-radius: 4rpx;
.css-ico-mail-flap {
width: 10rpx;
height: 10rpx;
border-width: 3rpx;
top: -2rpx;
}
}
.css-ico-web--row {
width: 26rpx;
height: 26rpx;
border-width: 3rpx;
.css-ico-web-h {
height: 3rpx;
}
.css-ico-web-v {
width: 10rpx;
border-width: 3rpx;
}
}
.row-label {
flex-shrink: 0;
width: 96rpx;
@@ -897,7 +1434,6 @@ export default {
height: 28rpx;
}
}
}
/* 入场动效(逐项延迟由父组件传 animation-delay) */
.fade-up {
@@ -910,6 +1446,63 @@ export default {
transform: scale(0.98);
}
/* CSS 描边图标(2.17.1): iconfont 无邮件/网站字形, 纯 view 边框绘制,
颜色由内联 style 跟随 preset/theme 配色档(H5+小程序通用)。
顶层定义 —— 宫格(88rpx 图标块内)与资料行(--row 缩小版)共用同一套图形 */
.css-ico-mail {
position: relative;
width: 44rpx;
height: 32rpx;
border: 4rpx solid #B0B4BC;
border-radius: 6rpx;
box-sizing: border-box;
/* 封面折角: 只留左+下边的方块旋转 -45° 成 V 形折线 */
.css-ico-mail-flap {
position: absolute;
left: 50%;
top: -2rpx;
width: 16rpx;
height: 16rpx;
border-left: 4rpx solid #B0B4BC;
border-bottom: 4rpx solid #B0B4BC;
transform: translateX(-50%) rotate(-45deg);
}
}
.css-ico-web {
position: relative;
width: 40rpx;
height: 40rpx;
border: 4rpx solid #B0B4BC;
border-radius: 50%;
box-sizing: border-box;
overflow: hidden;
/* 赤道横线(色由内联 background 给出) */
.css-ico-web-h {
position: absolute;
left: 0;
top: 50%;
width: 100%;
height: 4rpx;
transform: translateY(-50%);
}
/* 经线椭圆: 左右双边框 + 椭圆圆角 */
.css-ico-web-v {
position: absolute;
left: 50%;
top: -2rpx;
bottom: -2rpx;
width: 14rpx;
transform: translateX(-50%);
border-left: 4rpx solid #B0B4BC;
border-right: 4rpx solid #B0B4BC;
border-radius: 50%;
}
}
@keyframes sk-shimmer {
0% {
background-position: -200rpx 0;

View File

@@ -66,12 +66,20 @@ export default {
miniConfig() {
const v = this.value || {};
return {
bgColor: v.bgColor,
nameColor: v.nameColor,
infoColor: v.infoColor,
labelColor: v.labelColor,
borderRadius: v.borderRadius,
padding: v.padding,
bgColor: v.bgColor,
nameColor: v.nameColor,
infoColor: v.infoColor,
labelColor: v.labelColor,
borderRadius: v.borderRadius,
padding: v.padding,
// 字号定制(px 口径, 缺省时基件按历史默认值渲染)
nameSize: v.nameSize,
cardTitleSize: v.cardTitleSize,
subSize: v.subSize,
labelSize: v.labelSize,
valueSize: v.valueSize,
actSize: v.actSize,
btnSize: v.btnSize,
showQuick: v.showQuick,
showDetailBtn: v.showDetailBtn,
detailBtnText: v.detailBtnText,
@@ -95,6 +103,27 @@ export default {
qrOffsetX: v.qrOffsetX,
qrOffsetY: v.qrOffsetY,
qrTipShow: v.qrTipShow,
// 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮
headStyle: v.headStyle,
headNameColor: v.headNameColor,
headSubColor: v.headSubColor,
actsLayout: v.actsLayout,
actGridColorMode: v.actGridColorMode,
showRowsTitle: v.showRowsTitle,
rowsTitle: v.rowsTitle,
showCopyBtn: v.showCopyBtn,
// 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图
actsPosition: v.actsPosition,
actsBlockBg: v.actsBlockBg,
actsBlockRadius: v.actsBlockRadius,
actsBlockPad: v.actsBlockPad,
actIcons: v.actIcons,
qrImage: v.qrImage,
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
actVis: v.actVis,
actLabels: v.actLabels,
actOrder: v.actOrder,
customActs: v.customActs,
};
},
boxStyle() {
@@ -176,7 +205,13 @@ export default {
this.$util.redirectTo('/pages_tool/card/list');
},
doAct(act) {
if (!act || !act.value) return;
if (!act) return;
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
if (act.type === 'save') {
this.goSaveContact();
return;
}
if (!act.value) return;
if (act.type === 'tel') {
uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} });
} else if (act.type === 'wx') {
@@ -222,6 +257,37 @@ export default {
if (!card || !card.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
},
/**
* 存通讯录(2.17.1): 与 diy-card-show 同构 —— 字段全部从 card 组装
* (rows 已过 shouldShow 白名单), 键名与 save-contact.vue onLoad 的 keys 一一对应。
*/
goSaveContact() {
const c = this.card || {};
const map = {};
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
if (r && r.field) map[r.field] = String(r.value || '');
});
// #ifdef H5
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
});
return;
// #endif
this.$util.redirectTo('/pages_tool/card/save-contact', {
name: c.name || '',
mobile: map.mobile || '',
company: c.company || '',
job_title: c.job_title || '',
email: map.email || '',
website: map.website || '',
address: map.address || '',
remark: map.remark || '',
wechat: map.wechat || '',
});
},
},
};
</script>

View File

@@ -280,6 +280,7 @@ export default {
height: this.qrSide + 'rpx',
// 外观衬底只包二维码图(边距不撑大总尺寸), 提示文字在衬底外
boxSizing: 'border-box',
};
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
if (this.qrStyle === 'card') {
@@ -394,7 +395,13 @@ export default {
if (act) this.doAct(act);
},
doAct(act) {
if (!act || !act.value) return;
if (!act) return;
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
if (act.type === 'save') {
this.goSaveContact();
return;
}
if (!act.value) return;
if (act.type === 'tel') {
uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} });
} else if (act.type === 'wx') {
@@ -441,6 +448,7 @@ export default {
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
},
/* ---------- 分享名片抽屉(点二维码弹出, 版式/文案对齐 pages_tool/card/detail.vue) ---------- */
openCardShare() {
this.showShare = true;
@@ -500,6 +508,39 @@ export default {
const src = this.$util.img(url);
uni.previewImage({ urls: [src], current: src });
},
/**
* 存通讯录(2.17.1): 字段全部从 card 组装 —— rows 已过 shouldShow
* 白名单(商家隐藏的字段不下发也不落通讯录, 隐私口径与后端一致),
* name/company/job_title 为公开头部字段直接取。
* 键名与 save-contact.vue onLoad 的 keys 一一对应。
*/
goSaveContact() {
const c = this.card || {};
const map = {};
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
if (r && r.field) map[r.field] = String(r.value || '');
});
// #ifdef H5
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
});
return;
// #endif
this.$util.redirectTo('/pages_tool/card/save-contact', {
name: c.name || '',
mobile: map.mobile || '',
company: c.company || '',
job_title: c.job_title || '',
email: map.email || '',
website: map.website || '',
address: map.address || '',
remark: map.remark || '',
wechat: map.wechat || '',
});
},
},
};
</script>

View File

@@ -35,6 +35,7 @@
* 入参(query, 均需 encodeURIComponent):
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
* action=mail 时复制 email 到剪贴板并返回(web-view 内 mailto: 不生效的兜底)
*/
export default {
data() {
@@ -70,6 +71,10 @@ export default {
if (options && options.action === 'call' && this.form.mobile) {
this.callNow();
}
// 发邮件动作: web-view 内 mailto: 不生效, 复制邮箱后返回名片页
if (options && options.action === 'mail' && this.form.email) {
this.copyEmail();
}
},
onReady() {
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
@@ -91,6 +96,22 @@ export default {
fail: () => {}
});
},
// 发邮件兜底: 小程序无唤起邮件 App 的 API, 复制邮箱 + 提示后返回原名片页
copyEmail() {
uni.setClipboardData({
data: this.form.email,
showToast: false,
success: () => {
uni.showToast({ title: '邮箱已复制,去邮件 App 粘贴发送', icon: 'none', duration: 2000 });
setTimeout(() => {
uni.navigateBack({ fail: () => {} });
}, 1400);
},
fail: () => {
uni.showToast({ title: '复制失败,邮箱:' + this.form.email, icon: 'none', duration: 3000 });
}
});
},
saveToContacts() {
if (this.saving) return;
if (!this.form.name && !this.form.mobile) {

View File

@@ -52,6 +52,23 @@ test('common: rowIconFor 字段→图标映射(无字形字段退化为空)', as
assert.equal(m.rowIconFor('custom'), '');
assert.equal(m.rowIconFor(''), '');
assert.equal(m.rowIconFor(undefined), '');
// 2.17.1: rowCssIconFor —— 邮箱/网站走 CSS 描边图标(信封/线框地球), 口径互补
assert.equal(m.rowCssIconFor('email'), 'mail');
assert.equal(m.rowCssIconFor('website'), 'web');
assert.equal(m.rowCssIconFor('mobile'), '', '有 iconfont 字形的字段不走 CSS 档');
assert.equal(m.rowCssIconFor(''), '');
assert.equal(m.rowCssIconFor(undefined), '');
// 2.18.1: 宫格图标 = WAP_LINE_ICONS(与 wap detail 快捷操作同源 SVG 线稿), wx/save 固定色
assert.equal(m.WAP_LINE_ICONS.tel.mode, 'stroke');
assert.equal(m.WAP_LINE_ICONS.wx.mode, 'fill');
assert.equal(m.WAP_LINE_ICONS.wx.color, '#07C160', 'wx 微信绿固定色');
assert.equal(m.WAP_LINE_ICONS.save.color, '#FF9500', 'save 通讯录橙固定色');
assert.equal(m.WAP_LINE_ICONS.mail.mode, 'stroke');
assert.equal(m.WAP_LINE_ICONS.web.mode, 'stroke');
assert.ok(m.svgLineIconBg('unknown', '#000') === '', '未知类型空串');
assert.ok(m.svgLineIconBg('tel', '#0F6E56').indexOf('base64,') > 0, 'data URI base64 形态');
// 宫格 preset 补 save 紫色档
assert.deepEqual(m.ACT_GRID_PRESETS.save, { bg: '#F0E9FB', fg: '#5B3DA8' });
});
test('common: rowActOf 由 href 派生点击动作', async () => {
@@ -83,10 +100,13 @@ test('common: 二维码四档归一化 + 尺寸表', async () => {
// =============================================================
test('mini: 资料行图标列 + 文字标签退化', () => {
assert.match(miniSrc, /<text v-if="rowIcon\(row\)" class="row-icon iconfont" :class="rowIcon\(row\)"/);
// 2.17.1: 行首新增 CSS 描边图标档(邮箱/网站), iconfont 档退为 v-else-if
assert.match(miniSrc, /<view v-if="rowCssIcon\(row\)" class="row-css-wrap">/);
assert.match(miniSrc, /<text v-else-if="rowIcon\(row\)" class="row-icon iconfont" :class="rowIcon\(row\)"/);
assert.match(miniSrc, /<text v-else class="row-label"/);
// rowIcon 受 showRowIcons 开关控制(默认开)
// rowIcon 受 showRowIcons 开关控制(默认开); CSS 档同受此开关
assert.match(miniSrc, /rowIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
assert.match(miniSrc, /rowCssIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
});
test('mini: 资料行可点(开关 + href 派生 + emit row-act)', () => {
@@ -109,8 +129,10 @@ test('mini: 二维码四档渲染分支齐全', () => {
assert.match(miniSrc, /v-if="showQrBottom" class="mini-qr mini-qr-center"/);
// 居中只加左右 auto 边距, 不碰 corner/head 档定位
assert.match(miniSrc, /&\.mini-qr-center \{\s*margin-left: auto;\s*margin-right: auto;\s*\}/);
// 总条件仍要求名片有 qr_url
assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!\(this\.card && this\.card\.qr_url\)/);
// 总条件: 开关 + 有码源(qrSrc: 自定义图优先, 回落名片码)
assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/);
// 统一码源 qrSrc: 自定义图优先, 回落名片码(2.18)
assert.match(miniSrc, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)[\s\S]*?card && this\.card\.qr_url/);
});
test('mini: corner 档 Logo 避让 + 资料行/操作区动态让位', () => {
@@ -119,7 +141,9 @@ test('mini: corner 档 Logo 避让 + 资料行/操作区动态让位', () => {
// 头部水平让位 = cornerHeadStyle 动态反算(审计修复: 静态 180rpx 在自定义大尺寸下不足)
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
// 资料行/操作区让位 = cornerClearStyle 动态反算(自定义大尺寸/关 tip 自动跟随, 不用 calc)
assert.match(miniSrc, /:style="cornerClearStyle"/);
// 2.18: 让位经 rowsClearStyle 分流(操作区在上方时资料行不重复位移); 操作区为 独立块样式+让位 合并绑定
assert.match(miniSrc, /rowsClearStyle\(\)\s*\{[\s\S]*?this\.actsAbove \? \{\} : this\.cornerClearStyle/);
assert.match(miniSrc, /:style="\[actsBlockStyle, cornerClearStyle\]"/);
// 静态 CSS 让位必须已移除(动态值才是单一事实来源)
assert.ok(!/padding-right: 180rpx;/.test(miniSrc), '静态 180rpx 让位应废弃, 改由 cornerHeadStyle 动态给出');
// 浮层 CSS 只定位于层次; 视觉衬底(白卡等)由 qrBlockStyle 内联决定

View File

@@ -0,0 +1,215 @@
/**
* 名片 DIY 组件 · 字号定制化三层口径契约测试
*
* 锁死: diy_view.php 默认值 ↔ design.html 面板(slide+预览消费) ↔ C 端 vue 消费
* 口径: 字号字段一律 px 配置, C 端渲染 ×2 转 rpx(px2rpx); 默认值 = 历史硬编码值
*
* 派生关系(保证默认渲染与历史一致):
* - list 横滑竖卡: 姓名/信息比网格小一档(-1px)
* - list 人数徽标: 比信息行小一档(-1px); 「查看全部」= 信息字号
* - entry 文字箭头的箭头: 比按钮文字大一档(+2px)
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BE = resolve(__dirname, '../../backend/src/addon/card');
const FE = resolve(__dirname, '../components-diy');
/** 三层口径定义: 字段 → [默认值(px), 消费侧期望字面量] */
const CONTRACT = {
entry: {
defaults: { titleSize: 16, subSize: 12, btnSize: 12 },
colors: { bgColorStart: '#2F6BFF', bgColorEnd: '#1E40AF', textColor: '#FFFFFF', accentColor: '#FFEB3B' },
layout: { borderRadius: 12, padding: 16 },
vueRead: 'this.v.',
vueFile: 'diy-card-entry.vue',
},
list: {
defaults: { titleSize: 16, nameSize: 14, infoSize: 11 },
colors: { bgColor: '#FFFFFF', titleColor: '#333333', nameColor: '#1F2329', infoColor: '#888888' },
layout: { borderRadius: 12, padding: 12, gap: 10 },
vueRead: 'this.v.',
vueFile: 'diy-card-list.vue',
},
show: {
defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 },
colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' },
layout: { borderRadius: 16, padding: 16 },
vueRead: 'this.c.',
vueFile: 'diy-card-mini.vue',
},
self: {
defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 },
colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' },
layout: { borderRadius: 16, padding: 16 },
vueRead: 'this.c.',
vueFile: 'diy-card-mini.vue',
},
};
/** 从 diy_view.php 提取组件 value JSON(容忍单引号 PHP 串内的转义) */
const PHP_NAMES = { entry: 'DiyCardEntry', list: 'DiyCardList', show: 'DiyCardShow', self: 'DiyCardSelf' };
function readValueJson(component) {
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const re = new RegExp("'name'\\s*=>\\s*'" + PHP_NAMES[component] + "'[\\s\\S]*?'value'\\s*=>\\s*'([^\']+)'");
const m = php.match(re);
assert.ok(m, 'diy_view.php 中找到组件 ' + PHP_NAMES[component]);
return JSON.parse(m[1]);
}
test('diy_view.php 四组件默认值包含全部字号字段且等于历史渲染值', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const value = readValueJson(comp);
for (const [field, dft] of Object.entries(cfg.defaults)) {
assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft);
}
}
});
test('diy_view.php 配色/圆角间距默认值与契约一致(重置按钮的最终事实来源)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const value = readValueJson(comp);
for (const [field, dft] of Object.entries(cfg.colors)) {
assert.equal(String(value[field]).toUpperCase(), dft.toUpperCase(), comp + '.' + field + ' 颜色默认值应为 ' + dft);
}
for (const [field, dft] of Object.entries(cfg.layout)) {
assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft);
}
}
});
test('design.html 样式面板提供一键恢复默认, 重置字段与默认值逐一对应', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.ok(html.includes('diy-card-reset'), comp + ' 样式面板缺少恢复默认按钮');
const btn = html.match(/<button type="button" class="diy-card-reset" @click="([^"]+)"/);
assert.ok(btn, comp + ' 重置按钮缺少内联 @click 赋值');
const click = btn[1];
// 字号(px 数值)
for (const [field, dft] of Object.entries(cfg.defaults)) {
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
}
// 颜色(带引号)
for (const [field, dft] of Object.entries(cfg.colors)) {
assert.ok(click.includes("nc." + field + "='" + dft + "'"), comp + ' 重置缺少 ' + field + "='" + dft + "'");
}
// 圆角/间距(px 数值)
for (const [field, dft] of Object.entries(cfg.layout)) {
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
}
}
});
test('四个 design.html 面板: 每个字号字段都有 slide 控件', () => {
for (const comp of ['diy-card-entry', 'diy-card-list', 'diy-card-show', 'diy-card-self']) {
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
const fields = Object.keys(CONTRACT[comp.replace('diy-card-', '')].defaults);
for (const field of fields) {
assert.match(html, new RegExp("<slide :data=\"\\{ field: '" + field + "'"), comp + ' 面板缺少 ' + field + ' 的 slide');
}
}
});
test('design.html 预览必须消费字号字段(防「面板可配预览不动」漂移)', () => {
const expectPreview = {
'diy-card-entry': ['nc.titleSize', 'nc.subSize', 'nc.btnSize'],
'diy-card-list': ['nc.titleSize', 'nc.nameSize', 'nc.infoSize'],
'diy-card-show': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
'diy-card-self': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
};
for (const [comp, tokens] of Object.entries(expectPreview)) {
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
for (const token of tokens) {
assert.ok(html.includes(token), comp + ' 预览未消费 ' + token);
}
}
});
test('C 端 vue 按默认值消费字号字段(px2rpx 渲染)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const src = readFileSync(resolve(FE, cfg.vueFile), 'utf8');
for (const [field, dft] of Object.entries(cfg.defaults)) {
const literal = 'num(' + cfg.vueRead + field + ', ' + dft + ')';
assert.ok(src.includes(literal), cfg.vueFile + ' 缺少消费 ' + literal);
}
}
});
test('self.vue miniConfig 透传全部字号字段到基件', () => {
const src = readFileSync(resolve(FE, 'diy-card-self.vue'), 'utf8');
for (const field of Object.keys(CONTRACT.self.defaults)) {
assert.ok(src.includes(field + ': v.' + field), 'miniConfig 缺少透传 ' + field);
}
});
test('派生关系: 横滑/徽标 -1 档, 箭头 +1 档(默认渲染与历史一致)', () => {
const list = readFileSync(resolve(FE, 'diy-card-list.vue'), 'utf8');
assert.ok(list.includes("num(this.v.nameSize, 14) - 1"), 'list 横滑姓名应比网格小 1px');
assert.ok(list.includes("num(this.v.infoSize, 11) - 1"), 'list 横滑信息/人数徽标应比网格小 1px');
const entry = readFileSync(resolve(FE, 'diy-card-entry.vue'), 'utf8');
assert.ok(entry.includes("num(this.v.btnSize, 12) + 2"), 'entry 箭头应比按钮文字大 2px(28rpx)');
});
test('design.html 每个 slide 控件都带 default 且与 diy_view.php 注册值一致(逐项重置口径)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
const value = readValueJson(comp);
// 所有 <slide> 都必须携带 default(含内容页的 maxRows/qrSize)
const slides = html.match(/<slide[^>]*>/g) || [];
assert.ok(slides.length > 0, comp + ' 应存在 slide 控件');
for (const tag of slides) {
const fm = tag.match(/field:\s*'([A-Za-z0-9]+)'/);
const dm = tag.match(/default:\s*(-?\d+)/);
assert.ok(fm && dm, comp + ' slide 缺 field 或 default: ' + tag);
const field = fm[1];
const dft = Number(dm[1]);
assert.equal(
Number(value[field]), dft,
comp + '.' + field + ' slide default=' + dft + ' 应与 diy_view.php 值 ' + value[field] + ' 一致'
);
}
// 契约中全部字号字段的 slide 必须存在(防漏配)
const allFields = slides.map(t => (t.match(/field:\s*'([A-Za-z0-9]+)'/) || [])[1]);
for (const field of Object.keys(cfg.defaults)) {
assert.ok(allFields.includes(field), comp + ' 缺少 ' + field + ' 的 slide(default 参数)');
}
}
});
test('design.html 颜色控件带 value 兜底(= defaultColor, 防存量行缺字段回落 #303133)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
const tags = html.match(/<color :data="\{[^}]+\}"><\/color>/g) || [];
assert.ok(tags.length >= Object.keys(cfg.colors).length, comp + ' color 控件数量不足');
for (const [field, dft] of Object.entries(cfg.colors)) {
const tag = tags.find(t => t.includes("field: '" + field + "'"));
assert.ok(tag, comp + ' 缺少 ' + field + ' 的 color 控件');
assert.ok(tag.includes("defaultColor: '" + dft + "'"), comp + '.' + field + ' defaultColor 应为 ' + dft);
assert.ok(tag.includes("value: '" + dft + "'"), comp + '.' + field + ' 缺 value 兜底(应为 ' + dft + ')');
}
}
});
test('核心 slide 组件支持 opt-in 逐项重置(不传 default 的调用方零影响)', () => {
const js = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/components.js'), 'utf8');
assert.ok(js.includes('v-if="canReset"'), 'slide 模板缺少 canReset 条件渲染');
assert.ok(js.includes('resetDefault'), 'slide 缺少 resetDefault 方法');
// canReset 只能由 default 参数打开
assert.ok(js.match(/compData\.default !== undefined[\s\S]{0,60}canReset = true/), 'canReset 开关未绑定 default 参数');
// 颜色控件的平台自带重置仍在(逐项重置的颜色口径)
assert.ok(js.includes('class="color-selector-reset text-color" @click="reset()"'), 'color 组件内置重置丢失');
});
test('反例自测: 契约定义缺字段时测试必须失败', () => {
// 校验 readValueJson 真的在解析 diy_view.php 而非恒真
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
assert.ok(!php.includes('"titleSizeNonExist"'), '探针字段不应存在(存在则本测试失效)');
for (const comp of Object.keys(CONTRACT)) {
const value = readValueJson(comp);
assert.equal(value.titleSizeNonExist, undefined, comp + ' 不应有探针字段');
}
});

View File

@@ -0,0 +1,139 @@
/**
* 名片 DIY 组件 · 跳转目标(含平台统一「系统链接」)链路测试
*
* 背景:
* 商家在设计面板给「名片入口横幅 / 团队名片墙」配跳转目标, 有四档:
* list → 名片夹列表(内置)
* contacts→ 我的联系人(内置)
* custom → 自定义站内路径
* link → 平台统一「系统链接」(商品/分类/微页面/小程序/拨号/自定义外链…)
* 第四档由后台的 nc-link 组件写入一个 link 对象
* {name,title,wap_url,page_key,page_params}, C 端必须走 $util.diyRedirectTo
* 交给平台统一处理(内部页/外链/小程序/拨号), 不能自己拼 URL。
*
* 覆盖:
* 1. entry/list 的 link 模式走 diyRedirectTo(而不是 redirectTo)
* 2. 未选链接时有守卫(不跳空)
* 3. 内置三档仍走 redirectTo(没被新分支吃掉)
* 4. 反例: 不存在虚构模式/虚构字段
*
* 跑法: node --test tests/diy-card-link-target.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const DIY_DIR = path.resolve(__dirname, '../components-diy');
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
/** 剔除三类注释: HTML <!-- --> / 块 /* *\/ / 行 // (保护 https:// 里的 //) */
function stripComments(src) {
return src
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
}
const entrySrc = stripComments(read('diy-card-entry.vue'));
const listSrc = stripComments(read('diy-card-list.vue'));
/** 取某个方法体(从方法名到下一个同级方法定义) */
function methodBody(src, name) {
const start = src.indexOf(name + '(');
if (start === -1) return '';
// 从 '{' 开始做括号配平
let i = src.indexOf('{', start);
if (i === -1) return '';
let depth = 0;
for (let j = i; j < src.length; j++) {
if (src[j] === '{') depth++;
else if (src[j] === '}') {
depth--;
if (depth === 0) return src.slice(start, j + 1);
}
}
return src.slice(start);
}
const entryGoLink = methodBody(entrySrc, 'goLink');
const listGoMore = methodBody(listSrc, 'goMore');
// =============================================================
// 1. link 模式走平台统一跳转
// =============================================================
test('entry: goLink 的 link 模式走 $util.diyRedirectTo(平台统一处理链接对象)', () => {
assert.match(entryGoLink, /linkMode\s*===\s*'link'/);
assert.match(entryGoLink, /this\.\$util\.diyRedirectTo\(link\)/);
// link 模式必须**提前 return**, 不能落到下面的 redirectTo(url)
const linkBranch = entryGoLink.slice(entryGoLink.indexOf("linkMode === 'link'"));
assert.match(linkBranch.slice(0, 400), /return;/);
});
test('list: goMore 的 link 模式走 $util.diyRedirectTo', () => {
// list 的写法是先归一 mode = String(v.moreLinkMode || 'list'), 再按 mode 分支
assert.match(listGoMore, /const\s+mode\s*=\s*String\(this\.v\.moreLinkMode\s*\|\|\s*'list'\)/);
assert.match(listGoMore, /mode\s*===\s*'link'/);
assert.match(listGoMore, /this\.\$util\.diyRedirectTo\(link\)/);
const linkBranch = listGoMore.slice(listGoMore.indexOf("mode === 'link'"));
assert.match(linkBranch.slice(0, 400), /return;/);
});
test('entry: link 取的是配置里的 link 对象(不是 wap_url 字符串)', () => {
assert.match(entryGoLink, /v\.link\s*&&\s*typeof\s+v\.link\s*===\s*'object'/);
});
test('list: link 取的是配置里的 moreLink 对象', () => {
assert.match(listGoMore, /this\.v\.moreLink\s*&&\s*typeof\s+this\.v\.moreLink\s*===\s*'object'/);
});
// =============================================================
// 2. 未选链接的守卫(不跳空)
// =============================================================
test('entry: 未选链接时提示而不是跳空', () => {
assert.match(entryGoLink, /if\s*\(!link\s*\|\|\s*!link\.name\)/);
assert.match(entryGoLink, /请先选择链接/);
});
test('list: 未选链接时提示而不是跳空', () => {
assert.match(listGoMore, /if\s*\(!link\s*\|\|\s*!link\.name\)/);
assert.match(listGoMore, /请先选择链接/);
});
// =============================================================
// 3. 内置三档仍走 redirectTo(没被新分支吃掉)
// =============================================================
test('entry: 内置三档仍是 redirectTo(名片夹/联系人/自定义路径)', () => {
assert.match(entryGoLink, /linkMode\s*===\s*'contacts'/);
assert.match(entryGoLink, /\/pages_tool\/card\/contacts/);
assert.match(entryGoLink, /linkMode\s*===\s*'custom'/);
assert.match(entryGoLink, /\/pages_tool\/card\/list/);
assert.match(entryGoLink, /this\.\$util\.redirectTo\(url\)/);
});
test('list: 内置三档仍是 redirectTo', () => {
assert.match(listGoMore, /mode\s*===\s*'contacts'/);
assert.match(listGoMore, /\/pages_tool\/card\/contacts/);
assert.match(listGoMore, /mode\s*===\s*'custom'/);
assert.match(listGoMore, /this\.\$util\.redirectTo\(url\)/);
});
// =============================================================
// 反例自测(防恒真): 虚构模式/虚构字段必须不存在
// =============================================================
test('反例: 两个组件都不存在虚构跳转模式', () => {
assert.ok(!entrySrc.includes("linkMode === 'nosuchmode'"));
assert.ok(!listSrc.includes("moreLinkMode === 'nosuchmode'"));
});
test('反例: 两个组件都不存在虚构链接字段', () => {
assert.ok(!entrySrc.includes('v.linkMagic'));
assert.ok(!listSrc.includes('v.moreLinkMagic'));
});

View File

@@ -41,6 +41,20 @@ const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'
const showPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8')) : '';
const selfPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8')) : '';
/**
* 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组,
* 数字框 → 滑块):
* ① v-model="nc.X" 输入框 / 开关
* ② nc.X = 'v' 可视化选项组(芯片)的赋值绑定
* ③ field: 'X' 滑块控件 :data="{field:'X'}"
* 断言的是「这个字段确实被某个控件绑定了」, 不限定控件形态 —— 契约是字段名, 不是控件类型。
*/
const bound = (src, field) => new RegExp(
'v-model(\\.[a-z]+)?="nc\\.' + field + '"'
+ '|\\bnc\\.' + field + "\\s*=\\s*['\"]"
+ '|field\\s*:\\s*[\'"]' + field + '[\'"]'
).test(src);
// =============================================================
// 1. common.js 纯函数(真值断言)
// =============================================================
@@ -200,7 +214,7 @@ test('mini: qrBlockStyle 四档外观分支(多彩只装饰外围, 码黑白保
// 2026-10-23 用户定稿: 外观衬底只包二维码图(qrImgStyle), 「扫码查看完整名片」tip 在衬底外无蓝底
assert.match(miniSrc, /class="qr-img" :style="qrImgStyle"/);
const tipBinds = miniSrc.match(/class="qr-tip" :style="labelStyle"/g) || [];
assert.equal(tipBinds.length, 4, '四档 tip 仍独立(不进衬底), 实际 ' + tipBinds.length);
assert.equal(tipBinds.length, 5, '五档 tip 仍独立(不进衬底; 含 theme 头部块), 实际 ' + tipBinds.length);
});
test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => {
@@ -218,15 +232,15 @@ test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => {
test('mini: 扫码提示开关 + 四个挂点统一消费', () => {
assert.match(miniSrc, /showQrTip\(\)\s*\{\s*return flagOn\(this\.c\.qrTipShow,\s*true\)/);
// 四个二维码块(corner/head/rows/bottom)各有一个 tip, 都受开关控制
// 二维码 tip 挂点: corner/rows/bottom 各 1 + head 档 2(head 双形态分支互斥渲染, 2.17)
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
assert.equal(tips.length, 4, '四档二维码 tip 挂点, 实际 ' + tips.length);
assert.equal(tips.length, 5, 'tip 挂点(4 档 + head 双分支各一), 实际 ' + tips.length);
});
test('mini: cornerClearStyle 按实际尺寸反算让位(防遮挡, 纯数值不用 calc)', () => {
assert.match(miniSrc, /cornerClearStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
// 让位量 = 浮层 top 20 + 块高[尺寸+衬底+tip] - 头部 104 + 间隙 10; 下限 24
assert.match(miniSrc, /Math\.max\(24, total - 104 \+ 10\)/);
assert.match(miniSrc, /Math\.max\(24, total - headHeight \+ 10\)/);
// 衬底与 tip 参与计算(关 tip 时让位自动收窄)
assert.match(miniSrc, /const tip = this\.showQrTip \? 42 : 0;/);
});
@@ -242,7 +256,7 @@ test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算'
test('mini: 四个二维码块统一绑 qrBlockStyle(尺寸/位移/外观单一事实来源)', () => {
const binds = miniSrc.match(/:style="qrBlockStyle"/g) || [];
assert.equal(binds.length, 4, 'corner/head/rows/bottom 四块, 实际 ' + binds.length);
assert.equal(binds.length, 5, 'qrBlockStyle 绑定(corner/rows/bottom 各 1 + head 双分支各一), 实际 ' + binds.length);
});
// =============================================================
@@ -400,8 +414,12 @@ for (const [name, panel] of hasPanels ? [['show', showPanel], ['self', selfPanel
assert.match(panel, /v-model\.number="nc\.qrOffsetY"/);
assert.match(panel, /v-model="nc\.qrTipShow"/);
// 二维码外观四选项(plain/card/theme/gradient)
for (const v of ['plain', 'card', 'theme', 'gradient']) {
assert.ok(panel.includes('<option value="' + v + '">'), '缺选项 ' + v);
assert.ok(
panel.includes("nc.qrStyle = '" + v + "'") || panel.includes('<option value="' + v + '">'),
'缺选项 ' + v
);
}
});
@@ -410,10 +428,9 @@ for (const [name, panel] of hasPanels ? [['show', showPanel], ['self', selfPanel
assert.ok(panel.includes('nc.showAvatar !== false'), '预览不消费 showAvatar');
// 二维码外观四档
assert.ok(panel.includes("nc.qrStyle === 'plain'"), '预览不消费 qrStyle');
// 自定义尺寸(0=自动: 三处默认 48/44/56 按档位)
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize : 48'), '预览不消费 qrSize(corner)');
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize : 44'), '预览不消费 qrSize(head)');
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize : 56'), '预览不消费 qrSize(rows/bottom)');
// 自定义尺寸: 只锁「自定义值优先于档位自动」这层语义, 不锁死具体像素值
// (档位默认值是可调的设计参数, 锁死会让改一次尺寸就得改一次测试)
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize'), '预览不消费 qrSize');
// 微调位移
assert.ok(panel.includes('nc.qrOffsetX || 0'), '预览不消费 qrOffsetX');
assert.ok(panel.includes('nc.qrOffsetY || 0'), '预览不消费 qrOffsetY');

View File

@@ -0,0 +1,597 @@
/**
* 名片 DIY 组件 · 2.17 双形态(渐变头部/宫格快捷操作/资料区标题/复制按钮)契约测试
*
* 锁死三层口径: diy_view.php 默认值 ↔ design.html 面板控件与预览 ↔ C 端 vue 消费
* 兼容铁律: 新字段默认值 = 历史形态(plain/chips/关), 存量组件行缺字段时基件
* flagOn/normalize 回落历史默认 —— 渲染零变化, 种子只补缺绝不 UPDATE
*
* 同时锁死 2.17 修复: show.vue miniConfig 补齐 7 个字号字段透传
* (此前 show 漏传, 面板配置字号不生效; self 已有)
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BE = resolve(__dirname, '../../backend/src/addon/card');
const FE = resolve(__dirname, '../components-diy');
/** 2.17 新字段 → 默认值(= 历史形态, 兼容存量行) */
const V2_FIELDS = {
headStyle: 'plain',
headNameColor: '#FFFFFF',
headSubColor: '#FFFFFF',
actsLayout: 'chips',
actGridColorMode: 'preset',
showRowsTitle: 0,
rowsTitle: '联系资料',
showCopyBtn: 0,
};
/** 2.18 新字段 → 默认值(= 历史形态, 兼容存量行) */
const V18_FIELDS = {
actsPosition: 'below',
actsBlockBg: '',
actsBlockRadius: 0,
actsBlockPad: 0,
qrImage: '',
};
const V18_ACT_TYPES = ['tel', 'wx', 'mail', 'web', 'save'];
const PHP_NAMES = { show: 'DiyCardShow', self: 'DiyCardSelf' };
function readValueJson(component) {
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const re = new RegExp("'name'\\s*=>\\s*'" + PHP_NAMES[component] + "'[\\s\\S]*?'value'\\s*=>\\s*'([^\']+)'");
const m = php.match(re);
assert.ok(m, 'diy_view.php 中找到组件 ' + PHP_NAMES[component]);
return JSON.parse(m[1]);
}
function readMiniVue() {
return readFileSync(resolve(FE, 'diy-card-mini.vue'), 'utf8');
}
function readCommonJs() {
return readFileSync(resolve(FE, 'diy-card-common.js'), 'utf8');
}
test('diy_view.php: Show/Self value 均含 8 个 2.17 字段且默认值=历史形态', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V2_FIELDS)) {
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
assert.equal(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
}
}
});
test('diy_view.php: Show/Self value 均含 2.18 字段(actIcons 五类型完整结构)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V18_FIELDS)) {
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
assert.deepEqual(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
}
// actIcons: 必须是完整五类型结构(旧行 refreshComponent 深拷贝补全后, 面板
// v-model="nc.actIcons[type].xxx" 才不会因缺键报错); 2.19 起含 iconKey(素材库)
assert.ok(v.actIcons && typeof v.actIcons === 'object', comp + '.actIcons 已注册');
for (const t of V18_ACT_TYPES) {
assert.deepEqual(v.actIcons[t], { img: '', iconKey: '', svg: '', color: '', bg: '' }, comp + '.actIcons.' + t + ' 结构完整(含 svg 预览快照位)');
}
}
});
test('common.js: normalizeActsPosition + actIconCfgOf(2.18)', () => {
const js = readCommonJs();
const consts = js.match(/export const ACTS_POSITIONS = [^;]+;/)[0].replace('export const', 'const');
const pos = new Function('v', consts + '\n' + js.match(/export function normalizeActsPosition[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return normalizeActsPosition(v);');
assert.equal(pos(undefined), 'below', '缺省回落 below(历史形态)');
assert.equal(pos('nonsense'), 'below', '非法值回落 below');
assert.equal(pos('above'), 'above', 'above 透传');
assert.equal(pos('below'), 'below', 'below 透传');
const cfgOf = new Function('actIcons', 'type', js.match(/export function actIconCfgOf[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return actIconCfgOf(actIcons, type);');
assert.deepEqual(cfgOf(undefined, 'tel'), {}, 'actIcons 缺省 → 空对象');
assert.deepEqual(cfgOf({}, 'tel'), {}, 'actIcons 空表 → 空对象');
assert.deepEqual(cfgOf({ tel: { img: 'a.png' } }, 'tel'), { img: 'a.png' }, '按 type 取配置');
assert.deepEqual(cfgOf({ tel: 'bad' }, 'tel'), {}, '非对象配置 → 空对象');
});
test('common.js: normalize 非法/缺省值回落历史默认(兼容 1.0 铁律)', () => {
const js = readCommonJs();
// 函数体引用模块常量(HEAD_STYLES 等), 重构执行时一并提供作用域
const consts = js.match(/export const (HEAD_STYLES|ACTS_LAYOUTS|QR_POSITIONS|QR_STYLES) = [^;]+;/g)
.map(s => s.replace('export const ', 'const ')).join('\n');
for (const [fn, arg, expect, desc] of [
['normalizeHeadStyle', undefined, 'plain', '缺省回落 plain'],
['normalizeHeadStyle', 'nonsense', 'plain', '非法值回落 plain'],
['normalizeHeadStyle', 'theme', 'theme', 'theme 透传'],
['normalizeActsLayout', undefined, 'chips', '缺省回落 chips'],
['normalizeActsLayout', 'nonsense', 'chips', '非法值回落 chips'],
['normalizeActsLayout', 'grid', 'grid', 'grid 透传'],
['normalizeActGridColorMode', undefined, 'preset', '缺省回落 preset'],
['normalizeActGridColorMode', 'theme', 'theme', 'theme 透传'],
['normalizeActGridColorMode', 'nonsense', 'preset', '非法值回落 preset'],
]) {
const re = new RegExp('export function ' + fn + '\\(([^)]*)\\)\\s*\\{([\\s\\S]*?)\\n}');
const m = js.match(re);
assert.ok(m, 'common.js 存在 ' + fn);
const body = m[0].replace(/^export function /, 'function ');
const fnObj = new Function('v', consts + '\n' + body + '; return ' + fn + '(v);');
assert.equal(fnObj(arg), expect, fn + ': ' + desc);
}
});
test('common.js: hexToRgba 换算与反例 + ACT_GRID_ICONS 字形映射', () => {
const js = readCommonJs();
const hexToRgba = new Function('hex', 'alpha', js.match(/export function hexToRgba[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return hexToRgba(hex, alpha);');
assert.equal(hexToRgba('#2F6BFF', 0.12), 'rgba(47,107,255,0.12)');
assert.equal(hexToRgba('2F6BFF', 0.5), 'rgba(47,107,255,0.5)');
assert.equal(hexToRgba('#F8A', 0.2), 'rgba(255,136,170,0.2)', '3 位短写法展开(复审修复)');
assert.equal(hexToRgba('#XYZ', 0.5), null, '非法 hex 返回 null');
assert.equal(hexToRgba('', 0.5), null, '空串返回 null');
const icons = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0];
// 2.18.1: 宫格图标与 wap detail 快捷操作同源(五枚 SVG 线稿), wx/save 固定色
assert.match(icons, /tel:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /wx:\s*\{\s*mode:\s*'fill',\s*color:\s*'#07C160'/, 'wx 微信绿固定色(detail 同源)');
assert.match(icons, /mail:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /web:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /save:\s*\{\s*mode:\s*'stroke',\s*color:\s*'#FF9500'/, 'save 通讯录橙固定色(detail 同源)');
// svgLineIconBg: data URI 生成(stroke 模式颜色内嵌 / fill 模式固定色)
// 注入依赖: WAP_LINE_ICONS 常量 + 私有 b64ascii(含 B64T 字符表)
const wapConsts = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const');
const b64fn = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
const svgBg = new Function('type', 'color', wapConsts + '\n' + b64fn + '\n' + js.match(/export function svgLineIconBg[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return svgLineIconBg(type, color);');
const telBg = svgBg('tel', '#0F6E56');
assert.match(telBg, /^url\("data:image\/svg\+xml;base64,/, 'data URI base64 形态');
assert.ok(atob(telBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('stroke="#0F6E56"') >= 0, 'stroke 色内嵌');
const wxBg = svgBg('wx', '#123456');
assert.ok(atob(wxBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('fill="#07C160"') >= 0, 'wx 固定绿不随入参色');
assert.equal(svgBg('unknown', '#000'), '', '未知类型空串(首字兜底)');
// save 入多彩预设(紫色档)
assert.match(js, /save:\s*\{\s*bg:\s*'#F0E9FB',\s*fg:\s*'#5B3DA8'/, '宫格 preset 补 save 紫色档');
});
test('common.js: rowCssIconFor 资料行 CSS 图标(2.17.1)', () => {
const js = readCommonJs();
const rowConsts = js.match(/const ROW_CSS_ICONS = \{[\s\S]*?\};/)[0];
const fn = new Function('field', rowConsts + '\n' + js.match(/export function rowCssIconFor[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return rowCssIconFor(field);');
assert.equal(fn('email'), 'mail', '邮箱行走 CSS 信封');
assert.equal(fn('website'), 'web', '网站行走 CSS 线框地球');
assert.equal(fn('mobile'), '', '手机行仍用 iconfont 字形(rowIconFor 口径)');
assert.equal(fn(''), '');
assert.equal(fn(undefined), '');
});
test('mini.vue: theme 头部渲染分支 + 白字 fallback + 负 margin 通铺', () => {
const src = readMiniVue();
assert.match(src, /headTheme\(\)\s*\{[\s\S]*?normalizeHeadStyle\(this\.c\.headStyle\)\s*===\s*'theme'/, 'headTheme 消费 normalizeHeadStyle');
assert.match(src, /v-if="headTheme"[\s\S]*?mini-headtheme/, 'theme 档渲染渐变头部块');
assert.match(src, /v-if="!headTheme"[\s\S]*?class="mini-head"/, 'plain 档渲染白卡头部');
// 白字 fallback: headNameColor 缺省 → #FFFFFF; 副行 → 白 85%
assert.match(src, /this\.c\.headNameColor \|\| '#FFFFFF'/, '头部姓名色 fallback 白');
assert.match(src, /this\.c\.headSubColor \|\| 'rgba\(255,255,255,0\.85\)'/, '头部副行色 fallback 白 85%');
// 负 margin 通铺(不用 calc): margin: -{pad}rpx -{pad}rpx 0
assert.match(src, /margin: '-' \+ pad \+ 'rpx -' \+ pad \+ 'rpx 0'/, '头部块负 margin 通铺卡边');
// 空态提示不随 theme 变白(独立 emptyTipStyle)
assert.match(src, /emptyTipStyle\(\)/, '空态提示独立样式');
assert.doesNotMatch(src, /:style="subTextStyle">\{\{ emptyTip/, '空态不再复用 subTextStyle');
});
test('mini.vue: 宫格快捷操作分支 + preset/theme 双配色 + 首字兜底', () => {
const src = readMiniVue();
assert.match(src, /actsGrid\(\)\s*\{[\s\S]*?normalizeActsLayout\(this\.c\.actsLayout\)\s*===\s*'grid'/, 'actsGrid 消费 normalizeActsLayout');
// 2.18.1: 宫格图标 = SVG 线稿(与 wap detail 同源), img 覆盖优先, 未知类型首字兜底
assert.match(src, /mini-acts-grid[\s\S]*?actSvgOf\(act\)/, '宫格渲染用 actSvgOf');
assert.match(src, /v-else-if="actSvgOf\(act\)" class="act-grid-svg" :style="actSvgStyle\(act\)"/, 'SVG 线稿分支');
assert.match(src, /actInitialOf\(act\)/, '未知类型首字兜底');
assert.doesNotMatch(src, /act-grid-glyph/, 'iconfont 字形档已退出宫格(全 SVG)');
assert.match(src, /gridColorMode\(\)\s*\{[\s\S]*?normalizeActGridColorMode\(this\.c\.actGridColorMode\)/, '宫格配色模式消费 normalizeActGridColorMode');
// theme 档浅底 = detail 同款 145deg 12%→20% 主题渐变
assert.match(src, /linear-gradient\(145deg, ' \+ \(hexToRgba\(theme, 0\.12\) \|\| '#F2F3F5'\) \+ ', ' \+ \(hexToRgba\(theme, 0\.2\) \|\| '#F2F3F5'\)/, 'theme 档浅主题渐变底(detail 同源)');
assert.match(src, /ACT_GRID_PRESETS\[\(act && act\.type\) \|\| ''\] \|\| ACT_GRID_PRESET_FALLBACK/, 'preset 色板 + fallback');
// 带参样式函数必须在 methods(computed 不支持参数)
const methods = src.match(/methods:\s*\{[\s\S]*$/)[0];
assert.match(methods, /actGridIconStyle\(act\)/, 'actGridIconStyle 在 methods');
assert.match(methods, /actGridGlyphStyle\(act\)/, 'actGridGlyphStyle 在 methods');
// 2.18.1: 图标色口径 = 每按钮 color > detail 固定色(wx绿/save橙) > theme > preset
assert.match(methods, /actIconColor\(act\)[\s\S]*?cfgColor[\s\S]*?ico && ico\.color[\s\S]*?gridColorMode === 'theme'/, 'actIconColor 四级口径');
assert.match(methods, /actSvgStyle\(act\)\s*\{[\s\S]*?svgLineIconBg\(act && act\.type,\s*this\.actIconColor\(act\)\)/, 'actSvgStyle 消费 svgLineIconBg + actIconColor');
assert.match(src, /import \{[\s\S]*?WAP_LINE_ICONS, svgLineIconBg/, 'mini.vue 引入 SVG 图标源');
// CSS 图标基础样式仍须顶层定义(资料行 email/website 行还在用 --row 版)
assert.match(src, /\n\.css-ico-mail \{/, 'css-ico-mail 顶层定义(资料行消费)');
assert.match(src, /\n\.css-ico-web \{/, 'css-ico-web 顶层定义(资料行消费)');
// 5 动作一行: 列宽按 acts 数量动态(20% / 25%)
assert.match(src, /gridItemWidth\(\)\s*\{[\s\S]*?acts\.length > 4 \? '20%' : '25%'/, '宫格列宽 5 动作 20%');
assert.match(src, /:style="\{ width: gridItemWidth \}"/, '宫格列宽内联绑定');
// WXSS 禁 calc(): CSS 图标规则体内不得出现 calc(限定规则体内, 避免注释「不支持 calc()」字样假阳性)
assert.doesNotMatch(src, /css-ico[a-z-]*\s*\{[^{}]*calc\(/, 'CSS 描边图标无 calc');
});
test('mini.vue + show/self: save(存通讯录)动作全链路(2.17.1)', () => {
// 基件: 资料行 CSS 图标(邮箱/网站)与行图标开关
const src = readMiniVue();
assert.match(src, /rowCssIcon\(row\)\s*\{[\s\S]*?rowCssIconFor\(row && row\.field\)/, '资料行 CSS 图标消费 rowCssIconFor');
assert.match(src, /v-if="rowCssIcon\(row\)"[\s\S]*?css-ico-mail--row/, '资料行渲染缩小版信封');
assert.match(src, /row-css-wrap[\s\S]*?width: 96rpx/, 'CSS 行图标容器与标签列同宽对齐');
// show/self: doAct save 分支 + goSaveContact 字段组装(白名单口径)
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
const vue = readFileSync(resolve(FE, file), 'utf8');
assert.match(vue, /if \(act\.type === 'save'\)\s*\{\s*this\.goSaveContact\(\);\s*return;\s*\}/, file + ' doAct 加 save 分支');
assert.match(vue, /goSaveContact\(\)\s*\{[\s\S]*?save-contact/, file + ' 跳 save-contact 页');
// 字段组装自 rows(白名单)+ 公开头部字段, 键名与 save-contact.vue onLoad keys 一一对应
for (const k of ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat']) {
assert.match(vue, new RegExp(k + ": (c\\." + k + " || '')|(" + k + ": map\\." + k + " || '')"), file + ' 组装字段 ' + k);
}
assert.match(vue, /Array\.isArray\(c\.rows\)[\s\S]*?map\[r\.field\]/, file + ' 字段从 rows(白名单)提取');
assert.doesNotMatch(vue, /doAct\(act\)\s*\{\s*if \(!act \|\| !act\.value\) return;/, file + ' doAct 不再拦 value 为空的 save');
}
});
test('mini.vue: 资料区标题 + 复制按钮(默认关, emit row-act copy)', () => {
const src = readMiniVue();
assert.match(src, /rowsTitleShow\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showRowsTitle,\s*false\)/, '资料标题默认关');
assert.match(src, /showCopyBtn\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showCopyBtn,\s*false\)/, '复制按钮默认关');
assert.match(src, /v-if="rowsTitleShow"[\s\S]*?rowsTitleBarStyle/, '标题渲染含主题色竖条');
assert.match(src, /onCopyRow\(row\)[\s\S]*?\$emit\('row-act',\s*\{\s*type:\s*'copy',\s*value:\s*row\.value\s*\}\)/, '复制复用 row-act copy 分支');
assert.match(src, /@tap\.stop="onCopyRow\(row\)"/, '复制按钮阻断行点击冒泡');
});
test('复审修复(2.17 review): corner 让位计 theme 头高 / 圆角仅底部 / text-shadow 不泄漏 plain', () => {
const src = readMiniVue();
// corner 浮层让位按头部实际高反算(theme 档 164 = 32+104+28)
assert.match(src, /headHeight = this\.headTheme \? 164 : 104/, 'corner 让位区分 theme/plain 头部高度');
// theme 头部块通铺卡边后圆角只给底部(避免与卡片圆角叠加双圆角)
assert.match(src, /\.mini-headtheme \{[\s\S]*?border-radius: 0 0 16rpx 16rpx/, 'headtheme 圆角仅底部');
// text-shadow 只作用于 theme 档头像(scoped 父选择器 .mini-headtheme &), plain 档零变化
assert.match(src, /\.mini-headtheme & \{[\s\S]*?text-shadow/, 'text-shadow 限定 theme 档');
});
test('show.vue/self.vue: miniConfig 透传 8 个新字段; show 补齐 7 个字号(2.17 修复)', () => {
const sizes = ['nameSize', 'cardTitleSize', 'subSize', 'labelSize', 'valueSize', 'actSize', 'btnSize'];
const v2 = Object.keys(V2_FIELDS);
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
const src = readFileSync(resolve(FE, file), 'utf8');
for (const f of v2) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
// 2.18: 6 个新字段透传
for (const f of Object.keys(V18_FIELDS).concat(['actIcons'])) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
}
const show = readFileSync(resolve(FE, 'diy-card-show.vue'), 'utf8');
for (const f of sizes) {
assert.match(show, new RegExp(f + ':\\s*v\\.' + f + ','), 'show.vue 补齐字号透传 ' + f);
}
});
test('2.18: 操作区位置/独立样式/每按钮图标/自定义二维码 全链路', () => {
// 基件消费
const src = readMiniVue();
assert.match(src, /actsAbove\(\)\s*\{[\s\S]*?normalizeActsPosition\(this\.c\.actsPosition\)\s*===\s*'above'/, 'actsAbove 消费 normalizeActsPosition');
assert.match(src, /v-if="actsAbove && showActs && acts\.length && actsGrid"/, '上方档宫格分支渲染');
assert.match(src, /v-if="!actsAbove && showActs && acts\.length && actsGrid"/, '下方档宫格分支互斥守卫');
assert.match(src, /rowsClearStyle\(\)\s*\{[\s\S]*?return this\.actsAbove \? \{\} : this\.cornerClearStyle/, '上方档时资料行不再重复让位');
assert.match(src, /:style="\[actsBlockStyle, cornerClearStyle\]"/, '操作区块样式 + corner 让位合并绑定');
assert.match(src, /actsBlockStyle\(\)\s*\{[\s\S]*?actsBlockBg/, '独立块样式消费 actsBlockBg/Radius/Pad');
// 每按钮图标: img 优先于字形/CSS 图标; 前景/底色覆盖
assert.match(src, /actImgOf\(act\)\s*\{[\s\S]*?actIconCfgOf\(this\.c\.actIcons,\s*act && act\.type\)/, 'actImgOf 消费 actIconCfgOf');
assert.match(src, /<image v-if="actImgOf\(act\)" class="act-grid-img"/, '自定义图标图片渲染分支');
assert.match(src, /actGridIconStyle\(act\)\s*\{[\s\S]*?cfgBg[\s\S]*?if \(cfgBg\)/, '底色每按钮覆盖优先');
assert.match(src, /actIconColor\(act\)[\s\S]*?if \(custom\) return custom/, '前景色每按钮覆盖优先');
// 自定义二维码
assert.match(src, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)/, 'qrSrc 自定义图优先');
assert.match(src, /flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/, 'showQrBase 消费 qrSrc');
assert.equal((src.match(/\$util\.img\(qrSrc\)/g) || []).length, 5, '五个二维码挂点全部消费 qrSrc');
assert.doesNotMatch(src, /\$util\.img\(card\.qr_url\)/, '旧直连 card.qr_url 已移除');
// SCSS: 自定义图标图片铺满图标块
assert.match(src, /\.act-grid-img\s*\{[\s\S]*?width: 100%/, 'act-grid-img 铺满图标块');
// design.html ×2: 面板控件 + 预览消费
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.match(html, /@click="nc\.actsPosition = 'above'"/, comp + ' 操作区位置 chips(above)');
assert.match(html, /@click="nc\.actsPosition = 'below'"/, comp + ' 操作区位置 chips(below)');
assert.match(html, /@click="nc\.actsBlockBg = '#F7F8FA'; nc\.actsBlockPad = 12; nc\.actsBlockRadius = 12"/, comp + ' 独立成块预设联动');
assert.match(html, /field: 'actsBlockBg', label: '操作区底色'/, comp + ' 操作区底色 color');
assert.match(html, /field: 'actsBlockRadius', label: '操作区圆角'/, comp + ' 操作区圆角 slide');
assert.match(html, /field: 'actsBlockPad', label: '操作区内边距'/, comp + ' 操作区内边距 slide');
// 每按钮图标自定义(2.19 起并入「快捷操作管理」图层面板; 媒体库走类名委托)
assert.match(html, /快捷操作管理/, comp + ' 快捷操作管理图层面板');
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库选图入口已移除(2.19.1 图标唯一来源=素材图标库)');
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="color"/, comp + ' 每按钮前景色输入(2.19 起走 js-act-color 委托, $set 由委托 JS 兼容存量行)');
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="bg"/, comp + ' 每按钮底色输入(同上)');
// 自定义二维码(媒体库)
assert.match(html, /v-model="nc\.qrImage"/, comp + ' 二维码自定义图输入');
assert.match(html, /openAlbum\(function \(obj\)[\s\S]*?nc\.qrImage = obj\[0\]\.pic_path/, comp + ' 二维码选图走 openAlbum 媒体库');
// 预览消费
assert.match(html, /nc\.actsPosition === 'above'/, comp + ' 预览上方档分支');
assert.match(html, /nc\.actsPosition !== 'above'/, comp + ' 预览下方档互斥守卫');
assert.match(html, /diy-card-mini__act-img/, comp + ' 预览自定义图标分支');
assert.match(html, /diy-card-qr__img--custom/, comp + ' 预览自定义二维码分支');
}
// 平台: refreshComponent 对象默认值深拷贝(防同名组件共享嵌套对象串改)
const ct = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/custom_template.js'), 'utf8');
assert.match(ct, /JSON\.parse\(JSON\.stringify\(defaults\[key\]\)\)/, 'refreshComponent 对象默认值深拷贝');
// 反例: entry/list 不注册 2.18 字段
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
const entry = JSON.parse(entryM[1]);
assert.equal(entry.actsPosition, undefined, 'entry 不注册 actsPosition');
assert.equal(entry.qrImage, undefined, 'entry 不注册 qrImage');
});
test('design.html ×2: 面板控件与预览消费齐备 + 互证 diy_view 默认值', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 面板: 双形态 chips + 开关 + 文案输入
assert.match(html, /@click="nc\.headStyle = 'plain'"/, comp + ' 头部形态 chips(plain)');
assert.match(html, /@click="nc\.headStyle = 'theme'"/, comp + ' 头部形态 chips(theme)');
assert.match(html, /@click="nc\.actsLayout = 'grid'"/, comp + ' 操作布局 chips(grid)');
assert.match(html, /@click="nc\.actGridColorMode = 'theme'"/, comp + ' 宫格配色 chips(theme)');
assert.match(html, /v-model="nc\.showRowsTitle"/, comp + ' 资料区标题开关');
assert.match(html, /v-model="nc\.rowsTitle"/, comp + ' 标题文案输入');
assert.match(html, /v-model="nc\.showCopyBtn"/, comp + ' 复制按钮开关');
// 预览消费
assert.match(html, /diy-card-mini__head--theme/, comp + ' 预览 theme 头部分支');
assert.match(html, /diy-card-mini__act-grid/, comp + ' 预览宫格分支');
assert.match(html, /diy-card-mini__rows-title/, comp + ' 预览资料标题');
assert.match(html, /diy-card-mini__row-copy/, comp + ' 预览复制按钮');
// 样式页: theme 档头部 color 控件 + 恢复默认按钮覆盖头部色
assert.match(html, /field: 'headNameColor', label: '头部姓名色', defaultColor: '#FFFFFF'/, comp + ' 头部姓名色 color');
assert.match(html, /field: 'headSubColor', label: '头部副行色', defaultColor: '#FFFFFF'/, comp + ' 头部副行色 color');
assert.match(html, /nc\.headNameColor='#FFFFFF'; nc\.headSubColor='#FFFFFF'/, comp + ' 恢复默认覆盖头部色');
}
});
test('反例自测: 新形态标记不得出现在未改造组件中', () => {
// entry/list 未接 2.17 双形态(它们没有 mini 基件), diy_view 默认值也不应出现
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
const entry = JSON.parse(entryM[1]);
assert.equal(entry.headStyle, undefined, 'entry 不注册 headStyle(基件专属)');
assert.equal(entry.actsLayout, undefined, 'entry 不注册 actsLayout(基件专属)');
// common.js 禁 calc 铁律: 新增代码不得引入 calc()
const common = readCommonJs();
assert.doesNotMatch(common, /calc\(/, 'common.js 无 calc(WXSS 不支持)');
});
/* ------------------------------------------------------------------
* 2.19 快捷操作: 显示控制 + 自定义操作 + 素材图标库 + 图层式管理
* ------------------------------------------------------------------ */
const V19_FIELDS = { actVis: 'object', actLabels: 'object', customActs: 'array', actOrder: 'array' };
const V19_ACT_LABELS = { tel: '打电话', wx: '微信号', mail: '发邮件', web: '网站', save: '存通讯录' };
test('diy_view.php: Show/Self value 均含 2.19 字段(默认值=全显/默认文案/空数组/历史顺序=零变化)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
// actVis: 五类型全 1(显示)
assert.ok(v.actVis && typeof v.actVis === 'object', comp + '.actVis 已注册');
assert.deepEqual(v.actVis, { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, comp + '.actVis 默认全显');
// actLabels: 五类型默认文案
assert.ok(v.actLabels && typeof v.actLabels === 'object', comp + '.actLabels 已注册');
assert.deepEqual(v.actLabels, V19_ACT_LABELS, comp + '.actLabels 默认文案');
// customActs: 空数组
assert.deepEqual(v.customActs, [], comp + '.customActs 默认空数组');
// actOrder(2.19.1): 历史渲染顺序
assert.deepEqual(v.actOrder, ['tel', 'wx', 'mail', 'web', 'save'], comp + '.actOrder 默认=历史顺序');
}
});
test('素材图标库三源同步: PHP CardIconLibrary ↔ 面板 card-icon-lib.js ↔ C端 common.js', () => {
// PHP 源: 解析 CardIconLibrary::ICONS(key => svg)
const php = readFileSync(resolve(BE, 'service/CardIconLibrary.php'), 'utf8');
const libRe = /'(\w+)'\s*=>\s*\[\s*'label'\s*=>\s*'[^']*',\s*'category'\s*=>\s*'[^']*',\s*'svg'\s*=>\s*'(<svg [^']*)',\s*\]/g;
const phpLib = {};
let m;
while ((m = libRe.exec(php)) !== null) {
phpLib[m[1]] = m[2];
}
assert.ok(Object.keys(phpLib).length >= 25, 'PHP 素材库图标数 >= 25, 实际 ' + Object.keys(phpLib).length);
assert.ok(phpLib.phone && phpLib.phone.indexOf('currentColor') > 0, 'phone 图标带 currentColor');
const extractJson = (src, anchor) => {
const i = src.indexOf(anchor);
assert.ok(i >= 0, '找到锚点 ' + anchor);
const j = src.indexOf('{', i);
// 前向扫描配对花括号(JSON 内无裸花括号字面量, 安全)
let depth = 0, k = j;
for (; k < src.length; k++) {
if (src[k] === '{') depth++;
else if (src[k] === '}') { depth--; if (depth === 0) break; }
}
return JSON.parse(src.slice(j, k + 1));
};
// 面板 resource JS
const panelJs = readFileSync(resolve(BE, 'component/view/public/js/card-icon-lib.js'), 'utf8');
const panelLib = extractJson(panelJs, 'window.CARD_ICON_LIB =');
assert.deepEqual(panelLib, phpLib, '面板图标库与 PHP 同源');
assert.match(panelJs, /function cardLibIconSvg\(key\)/, '面板提供 cardLibIconSvg');
assert.match(panelJs, /function cardLibIconBg\(key, color\)/, '面板提供 cardLibIconBg');
assert.match(panelJs, /split\('currentColor'\)\.join\(color/, '面板上色走 currentColor 替换');
// C端 common.js
const common = readCommonJs();
const feLib = extractJson(common, 'export const CARD_ICON_LIB =');
assert.deepEqual(feLib, phpLib, 'C端图标库与 PHP 同源');
assert.match(common, /export function libIconSvg\(key\)/, 'C端提供 libIconSvg');
assert.match(common, /export function libIconBg\(key, color\)/, 'C端提供 libIconBg');
});
test('common.js: normalizeActVis / normalizeActLabels / normalizeCustomActs / normalizeActOrder(2.19+2.19.1)', () => {
const js = readCommonJs();
const consts = [
js.match(/export const ACT_TYPES = [^;]+;/)[0].replace('export const', 'const'),
js.match(/export const ACT_ACTION_TYPES = [^;]+;/)[0].replace('export const', 'const'),
js.match(/export const ACT_LABEL_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const'),
js.match(/export const ACT_ORDER_DEFAULT = [^;]+;/)[0].replace('export const', 'const'),
].join('\n');
const grab = (name) => js.match(new RegExp('export function ' + name + '[\\s\\S]*?\\n}'))[0].replace('export function ', 'function ');
const body = consts + '\n' + ['normalizeActVis', 'normalizeActLabels', 'normalizeCustomActs', 'normalizeActOrder'].map(grab).join('\n') + '\n'
+ 'return [normalizeActVis, normalizeActLabels, normalizeCustomActs, normalizeActOrder];';
const [visFn, labelFn, customFn, orderFn] = new Function(body)();
// actVis: 缺省全显(历史零变化); '0' → 0; 未知键忽略
assert.deepEqual(visFn(undefined), { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, 'actVis 缺省全显');
assert.deepEqual(visFn({ tel: '0', wx: 0 }), { tel: 0, wx: 0, mail: 1, web: 1, save: 1 }, 'actVis 0=隐藏/缺键=显');
// actLabels: 缺省=内置文案; 空串回落默认
assert.deepEqual(labelFn(undefined), V19_ACT_LABELS, 'actLabels 缺省=内置默认');
assert.deepEqual(labelFn({ tel: '', wx: '加微信' }).wx, '加微信', 'actLabels 改名生效');
assert.equal(labelFn({ tel: '' }).tel, '打电话', 'actLabels 空串回落默认');
// customActs: 白名单过滤 + 字段收敛 + custom 标记
assert.deepEqual(customFn(undefined), [], 'customActs 缺省空');
const list = customFn([
{ label: '加群', type: 'url', value: 'https://x.com', vis: 1 },
{ type: 'tel', value: '13900000000' },
'junk',
]);
assert.equal(list.length, 2, '非对象项剔除');
assert.equal(list[0].type, 'copy', '非法 type 回落 copy');
assert.equal(list[0].custom, 1, '带 custom 标记');
assert.equal(list[0].label, '加群', 'label 保留');
assert.equal(list[1].label, '自定义', 'label 缺省');
assert.equal(list[1].type, 'tel', '合法 type 保留');
// actOrder(2.19.1): 缺省=历史顺序; 去重+白名单+缺失尾补
assert.deepEqual(orderFn(undefined), ['tel', 'wx', 'mail', 'web', 'save'], 'actOrder 缺省=历史顺序');
assert.deepEqual(orderFn(['save', 'tel', 'bogus', 'save', 'mail']), ['save', 'tel', 'mail', 'wx', 'web'], '重排去重+非法剔除+缺失尾补');
assert.deepEqual(orderFn('junk'), ['tel', 'wx', 'mail', 'web', 'save'], '非数组=历史顺序');
});
test('common.js: libIconBg 上色 + data URI 可解码回原图(2.19)', () => {
const js = readCommonJs();
const b64 = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
const libJson = js.slice(js.indexOf('{', js.indexOf('export const CARD_ICON_LIB =')));
// 前向扫描取 JSON
let depth = 0, end = 0;
for (let i = libJson.indexOf('{'); i < libJson.length; i++) {
if (libJson[i] === '{') depth++;
else if (libJson[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
}
const lib = new Function('return ' + libJson.slice(libJson.indexOf('{'), end))();
const fn = new Function('CARD_ICON_LIB', b64 + '\n' + js.match(/export function libIconSvg\(key\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\n' + js.match(/export function libIconBg\(key, color\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\nreturn libIconBg;')(lib);
const bg = fn('phone', '#FF0000');
assert.match(bg, /^url\("data:image\/svg\+xml;base64,/), 'libIconBg 输出 base64 data URI';
// 解码回 SVG 验证: currentColor 已替换为指定色
const b64str = bg.match(/base64,([^"]+)/)[1];
const svg = Buffer.from(b64str, 'base64').toString('ascii');
assert.ok(svg.indexOf('#FF0000') > 0, 'SVG 内嵌指定色');
assert.equal(svg.indexOf('currentColor'), -1, 'currentColor 已全部替换');
assert.equal(fn('not_exist', '#FFF'), '', '缺 key 返回空串');
});
test('mini.vue: acts 合成(显隐过滤 + 次序 + 文案覆盖 + 自定义动作尾接) + 素材库图标分支(2.19+2.19.1)', () => {
const src = readMiniVue();
// acts computed: 过滤/改名/合并/四 cfg 字段
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActVis\(this\.c\.actVis\)/, 'acts 消费 normalizeActVis');
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActLabels\(this\.c\.actLabels\)/, 'acts 消费 normalizeActLabels');
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeCustomActs\(this\.c\.customActs\)/, 'acts 消费 normalizeCustomActs');
// 2.19.1 次序: 按 normalizeActOrder 输出默认动作 + order 外扩展类型尾接兜底
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActOrder\(this\.c\.actOrder\)/, 'acts 消费 normalizeActOrder');
assert.match(src, /acts\(\)\s*\{[\s\S]*?const byType = \{\};[\s\S]*?order\.forEach\(t => \{[\s\S]*?if \(byType\[t\] && vis\[t\] !== 0\) out\.push\(merge\(byType\[t\]\)\);/, '按 order 次序取默认动作(vis=0 过滤)');
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(order\.indexOf\(t\) >= 0 \|\| vis\[t\] === 0\) return;/, 'order 外扩展类型尾接且隐藏不渲染');
assert.match(src, /labels\[t\] \|\| a\.label/, 'actLabels 覆盖文案(空回落服务端)');
assert.match(src, /cfgImg: String\(cfg\.img \|\| ''\)\.trim\(\)/, '默认动作合并 cfgImg');
assert.match(src, /cfgKey: String\(cfg\.iconKey \|\| ''\)\.trim\(\)/, '默认动作合并 cfgKey(iconKey)');
assert.match(src, /normalizeCustomActs\(this\.c\.customActs\)\.forEach\(ca => \{[\s\S]*?cfgKey: ca\.iconKey/, '自定义动作尾接并合并 cfg 字段');
// 图标渲染优先级: img > 素材库(iconKey) > SVG 线稿 > 首字
assert.match(src, /actLibOf\(act\)\s*\{[\s\S]*?act\.cfgKey/, 'actLibOf 读合并 cfgKey');
assert.match(src, /actLibStyle\(act\)\s*\{[\s\S]*?libIconBg\(this\.actLibOf\(act\), this\.actIconColor\(act\)\)/, 'actLibStyle 消费 libIconBg + 四级色口径');
assert.equal((src.match(/v-else-if="actLibOf\(act\)" class="act-grid-svg" :style="actLibStyle\(act\)"/g) || []).length, 2, '上/下两份宫格副本均插入素材库分支');
assert.match(src, /actSvgOf\(act\)\s*\{[\s\S]*?!this\.actImgOf\(act\) && !this\.actLibOf\(act\) && !!WAP_LINE_ICONS/, '线稿档被 img/素材库优先占位后不再进入');
assert.match(src, /import \{[\s\S]*?normalizeActVis, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg/, 'mini.vue 引入 2.19+2.19.1 依赖');
// 透传
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
const vue = readFileSync(resolve(FE, file), 'utf8');
for (const f of ['actVis', 'actLabels', 'actOrder', 'customActs']) {
assert.match(vue, new RegExp(f + ': v\\.' + f + ','), file + ' 透传 ' + f);
}
}
});
test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/文案/自定义动作消费(2.19)', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 图层管理器: 眼睛开关 / 图标缩略 / 行内改名 / 素材库+媒体库按钮 / 清除
assert.match(html, /diy-card-layer__eye layui-icon" :class="nc\.actVis && nc\.actVis\[a\.k\] === 0 \? 'layui-icon-eye-invisible' : 'layui-icon-eye'/, comp + ' 默认动作眼睛开关');
assert.match(html, /nc\.actVis\[a\.k\] = nc\.actVis\[a\.k\] === 0 \? 1 : 0;/, comp + ' 眼睛切换写回 actVis');
assert.match(html, /v-model="nc\.actLabels\[a\.k\]" maxlength="6"/, comp + ' 行内改名 v-model actLabels');
assert.match(html, /js-act-lib" :data-key="a\.k" data-scope="default"/, comp + ' 素材图标按钮(默认动作)');
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库按钮已移除(2.19.1)');
assert.match(html, /\$set\(nc\.actIcons\[a\.k\], 'iconKey', ''\)/, comp + ' 清除图标走 $set(iconKey 兼容存量行)');
// 自定义动作行: 类型 chips / 取值 / 排序 / 删除 / 添加
assert.match(html, /diy-card-layer--custom" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作图层行');
assert.match(html, /:class="\{ active: c\.type === t\.v \}"/, comp + ' 自定义动作类型 chips');
assert.match(html, /v-model="c\.value" maxlength="200"/, comp + ' 自定义动作取值输入');
assert.match(html, /js-act-lib" data-scope="custom" :data-idx="ci"/, comp + ' 素材图标按钮(自定义动作)');
assert.match(html, /nc\.customActs\.splice\(ci - 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 上移排序');
assert.match(html, /nc\.customActs\.splice\(ci \+ 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 下移排序');
assert.match(html, /nc\.customActs\.splice\(ci, 1\)/, comp + ' 删除自定义动作');
// 2.19.1 默认动作次序: 面板行按 actOrder 渲染 + ↑↓ 委托调整
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><div class="diy-card-layer"/g) || []).length, 1, comp + ' 面板默认行按 actOrder 次序渲染(嵌套 v-for + v-if 匹配)');
assert.equal((html.match(/js-act-move" title="上移" :data-key="a\.k" data-dir="up"/g) || []).length, 1, comp + ' 默认行上移按钮(委托)');
assert.equal((html.match(/js-act-move" title="下移" :data-key="a\.k" data-dir="down"/g) || []).length, 1, comp + ' 默认行下移按钮(委托)');
assert.match(html, /nc\.customActs\.push\(\{ label: '自定义', type: 'copy'[\s\S]*?svg: ''[\s\S]*?vis: 1 \}\)/, comp + ' 添加自定义操作(全键补齐含 svg)');
assert.match(html, /c\.vis = c\.vis === 0 \? 1 : 0;/, comp + ' 自定义动作眼睛开关');
// 预览消费: 次序 + 显隐过滤 + 文案覆盖 + 自定义动作单元格
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><template v-for="a in \[\{k:'tel',ch:'打',lb:'打电话'/g) || []).length, 2, comp + ' 预览默认 cell 按 actOrder 次序渲染(上/下两副本)');
assert.equal((html.match(/v-if="a\.k === k && !\(nc\.actVis && nc\.actVis\[a\.k\] === 0\)"/g) || []).length, 2, comp + ' 预览显隐过滤(嵌套 key 匹配)');
assert.match(html, /\{\{ \(nc\.actLabels && nc\.actLabels\[a\.k\]\) \|\| a\.lb \}\}/, comp + ' 预览文案覆盖');
// svg 快照口径: 预览只读纯数据字段(委托 JS 选图/改色时预计算 data URI 写入),
// 模板内严禁调用全局函数(模板作用域函数解析不可靠, 实测 typeof 恒 false)
assert.equal((html.match(/v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 3, comp + ' 自定义动作 v-for = 面板行 + 预览×2 副本');
assert.equal((html.match(/nc\.actIcons\[a\.k\]\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览默认动作素材库图标读 svg 快照(上/下两副本)');
assert.equal((html.match(/v-else-if="c\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览自定义动作素材库图标读 svg 快照(上/下两副本)');
assert.match(html, /nc\.actIcons\[a\.k\]\.svg" class="diy-card-layer__libico"/, comp + ' 面板行图标缩略读 svg 快照');
assert.match(html, /c\.svg" class="diy-card-layer__libico"/, comp + ' 自定义行图标缩略读 svg 快照');
assert.doesNotMatch(html, /cardLibIconBg/, comp + ' 模板内零函数调用(全部改读 svg 字段)');
// 前景/底色走 class 委托(不再内联 @input 多语句)
assert.equal((html.match(/class="diy-card-layer__color js-act-color"/g) || []).length, 4, comp + ' 4 个颜色输入走 js-act-color 委托');
assert.match(html, /data-field="color"/, comp + ' 前景色 data-field');
assert.match(html, /data-field="bg"/, comp + ' 底色 data-field');
assert.match(html, /data-scope="custom" :data-idx="ci" data-field=/, comp + ' 自定义行颜色输入带作用域');
// 结构铁律: 自定义动作 cell 必须在默认动作 template v-for 之外(否则每默认动作重复渲染一次);
// 2.19.1 起默认 cell 为双层嵌套 template(外层 actOrder 次序), 收尾双 </template>
assert.equal((html.match(/<\/template><\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 2, comp + ' 自定义动作 cell 在双层 template v-for 之外(上/下两副本)');
assert.doesNotMatch(html, /(?<!<\/template>)<\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不紧贴单层 template 收尾(嵌套层级正确)');
assert.doesNotMatch(html, /<\/div><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不再嵌套于 template v-for 内');
// template 标签必须配平(未闭合 template 会把后续编辑器内容吞进惰性节点, vue 全局不初始化)
assert.equal((html.match(/<template/g) || []).length, (html.match(/<\/template>/g) || []).length, comp + ' template 标签配平');
// 资源引用
assert.match(html, /js src="\{\$resource_path\}\/\.\.\/public\/js\/card-icon-lib\.js"/, comp + ' 引入素材图标库 JS');
assert.match(html, /js src="\{\$resource_path\}\/\.\.\/public\/js\/card-act-icon\.js"/, comp + ' 引入图标选择委托 JS');
}
});
test('card-act-icon.js: 类名委托修 2.18 选图标 Bug + $set 写回 + 素材库唯一图标来源 + 次序调整(2.19+2.19.1)', () => {
const src = readFileSync(resolve(BE, 'component/view/public/js/card-act-icon.js'), 'utf8');
assert.match(src, /on\('click', '\.js-act-lib'/, '素材图标库走类名委托');
assert.doesNotMatch(src, /js-act-media|openAlbum\(function/, '媒体库委托已整体移除(2.19.1 图标唯一来源=素材图标库)');
// 2.19.1 默认动作次序调整
assert.match(src, /on\('click', '\.js-act-move'/, '次序调整走类名委托');
assert.match(src, /var ACT_ORDER_DEF = \['tel', 'wx', 'mail', 'web', 'save'\];/, 'actOrder 白名单默认序');
assert.match(src, /function moveAct\(comp, key, dir\)/, 'moveAct 归一化+相邻交换');
assert.match(src, /window\.vue\.\$set\(comp, 'actOrder', cur\)/, 'actOrder 写回走 $set(存量行缺键)');
assert.match(src, /closest\('\.draggable-element'\)/, '组件实例定位 draggable-element');
assert.match(src, /data\('scope'\) \|\| 'default'\) === 'custom'/, 'custom 作用域写 customActs[idx]');
assert.match(src, /window\.vue\.\$set\(item, 'iconKey'/, 'iconKey 写回走 $set(存量行缺键)');
assert.match(src, /window\.vue\.\$set\(item, 'img'/, 'img 写回走 $set');
// svg 快照链路: 选图/改色时由委托 JS 预计算 data URI 写入 svg 字段(预览只读数据)
assert.match(src, /function setIcon\(item, patch\)/, 'setIcon 统一写图标');
assert.match(src, /window\.cardLibIconBg\(iconKey, currentColor\(item\)\)/, '选图时预计算 svg 快照');
assert.match(src, /function actColorHandler\(\$el, field\)/, '改色处理器');
assert.match(src, /on\('input', '\.js-act-color'/, '前景/底色走 input 类名委托');
assert.match(src, /window\.cardLibIconBg\(target\.item\.iconKey, currentColor\(target\.item\)\)/, '改色时重算 svg 快照(颜色实时生效)');
assert.match(src, /iconKey: '', svg: '', color: ''/, '缺行防御补齐含 svg 键');
assert.match(src, /window\.CARD_ICON_LIB \|\| \{\}/, '弹层渲染消费同源图标库');
assert.match(src, /cal-item" data-key=/, '弹层图标网格');
// 反例: 面板不再内联调 openAlbum 选快捷操作图标(2.18 Bug 根因规避)
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.doesNotMatch(html, /openAlbum\(function \(obj\) \{ if \(obj && obj\.length\) \{ if \(!nc\.actIcons\)/, comp + ' 面板不再内联调 openAlbum 写 actIcons');
}
});