/** * 名片 DIY 组件 · 4.0 头像开关 + 二维码多彩/微调 契约测试 * * 背景: 参考图复刻的第二轮增强: * - 头像可隐藏(关闭后头部只留姓名/职位, 信息密度可调) * - 二维码四档外观: plain 原图 / card 白卡 / theme 主题色 / gradient 渐变 * (多彩只装饰外围衬底, 码本身黑白保可扫 —— 不改共享 qr 生成端点) * - 自定义尺寸(0=按档位自动) + transform 微调位移(WXSS 不支持 calc, 用 translate) * - 扫码提示文案可关 * * 覆盖: * 1. common.js 纯函数(动态 import 真值断言, 非正则) * 2. mini.vue 基件契约(先剥注释再断言) * 3. show/self 6 字段透传 * 4. 两个 design.html 面板: 控件绑定 + 预览 mock 消费(属性变更预览同步) * 5. 反例: 虚构外观/虚构字段必须查不到(防恒真) * * 跑法: node --test tests/diy-card-qr-style.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 PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view'); const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8'); const strip = s => s .replace(//g, '') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/.*$/gm, '$1'); const showSrc = strip(read('diy-card-show.vue')); const selfSrc = strip(read('diy-card-self.vue')); const miniSrc = strip(read('diy-card-mini.vue')); // 面板目录属 backend 仓库, 可能不在本工作区: 缺失时跳过面板断言(组件契约照常跑) 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')) : ''; // ============================================================= // 1. common.js 纯函数(真值断言) // ============================================================= test('common: QR_STYLES 四档外观枚举', async () => { const m = await import('../components-diy/diy-card-common.js'); assert.deepEqual(m.QR_STYLES, ['plain', 'card', 'theme', 'gradient']); }); test('common: normalizeQrStyle 合法直通, 未知/空回落 card(默认白卡)', async () => { const m = await import('../components-diy/diy-card-common.js'); for (const s of ['plain', 'card', 'theme', 'gradient']) { assert.equal(m.normalizeQrStyle(s), s); } // 未配置(存量装修页无此字段) → card, 与 diy_view.php 默认值同口径 assert.equal(m.normalizeQrStyle(''), 'card'); assert.equal(m.normalizeQrStyle(undefined), 'card'); assert.equal(m.normalizeQrStyle(null), 'card'); assert.equal(m.normalizeQrStyle('rainbow'), 'card'); // 面板只下发小写档位; 大写属未知值, 回落 card(保守, 不炸) assert.equal(m.normalizeQrStyle('CARD'), 'card'); // 中文档位名别名(面板下拉若直接存中文档名, 归一化兜住, 否则 C 端永远回落 card) assert.equal(m.normalizeQrStyle('原图'), 'plain'); assert.equal(m.normalizeQrStyle('白卡描边'), 'card'); assert.equal(m.normalizeQrStyle('主题色衬底'), 'theme'); assert.equal(m.normalizeQrStyle('渐变圆角'), 'gradient'); }); test('common: normalizeQrPosition 位置档中文别名兼容', async () => { const m = await import('../components-diy/diy-card-common.js'); assert.equal(m.normalizeQrPosition('资料行下方'), 'rows'); assert.equal(m.normalizeQrPosition('头部右侧'), 'head'); assert.equal(m.normalizeQrPosition('右上角浮层'), 'corner'); assert.equal(m.normalizeQrPosition('主按钮上方'), 'bottom'); // 英文码直通 / 未知回落 rows assert.equal(m.normalizeQrPosition('bottom'), 'bottom'); assert.equal(m.normalizeQrPosition('nope'), 'rows'); }); test('common: darkenColor 压暗生成可见渐变终止色', async () => { const m = await import('../components-diy/diy-card-common.js'); // 纯色压暗 0.18: #119dff → #0e81d1 assert.equal(m.darkenColor('#119dff', 0.18), '#0e81d1'); // 比例越大约暗 assert.notEqual(m.darkenColor('#119dff', 0.4), m.darkenColor('#119dff', 0.18)); // 非法输入原样返回(不影响既有渲染) assert.equal(m.darkenColor('#12', 0.18), '#12'); assert.equal(m.darkenColor('rgb(1,2,3)', 0.18), 'rgb(1,2,3)'); }); test('common: rowIconFor 资料行图标字段映射(含方案1邮箱预留 icon-youxiang)', async () => { const m = await import('../components-diy/diy-card-common.js'); // 有字形的字段返回 iconfont 类 assert.equal(m.rowIconFor('mobile'), 'icon-dianhua'); assert.equal(m.rowIconFor('phone'), 'icon-dianhua'); assert.equal(m.rowIconFor('wechat'), 'icon-weixin'); assert.equal(m.rowIconFor('address'), 'icon-dizhi'); // 邮箱: 方案1 预留 icon-youxiang(需字库补充信封字形后方能显示), 多键名兜底 assert.equal(m.rowIconFor('email'), 'icon-youxiang'); assert.equal(m.rowIconFor('mail'), 'icon-youxiang'); assert.equal(m.rowIconFor('邮箱'), 'icon-youxiang'); // 网站(2026-10-22): 复用字库现成链条字形 icon-fuzhilianjie, 多键名兜底 assert.equal(m.rowIconFor('website'), 'icon-fuzhilianjie'); assert.equal(m.rowIconFor('link'), 'icon-fuzhilianjie'); assert.equal(m.rowIconFor('网站'), 'icon-fuzhilianjie'); assert.equal(m.rowIconFor('custom'), ''); assert.equal(m.rowIconFor(''), ''); }); test('common: iconfont.css 邮箱信封 SVG 覆盖(字库无字形期的降级实现)', () => { const iconSrc = fs.readFileSync(path.resolve(__dirname, '../common/css/iconfont.css'), 'utf8'); // 覆盖规则存在且 content 置空(避免渲染豆腐块), 尺寸/对齐与 row-icon 槽位匹配 assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?content: "";[\s\S]*?width: 30rpx;[\s\S]*?height: 30rpx;/); // 信封 SVG data-uri(描边风格, #999999 与资料行标签默认色一致) assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?data:image\/svg\+xml,[\s\S]*?stroke='%23999999'[\s\S]*?%3E%3Crect x='2' y='4' width='20' height='16'[\s\S]*?center\/contain no-repeat/); }); test('common: resolveQrSize 自定义>档位自动, 单位 px→rpx(×2)', async () => { const m = await import('../components-diy/diy-card-common.js'); // 自定义尺寸(面板 px) × 2 = rpx assert.equal(m.resolveQrSize('rows', 120), 240); assert.equal(m.resolveQrSize('corner', 60), 120); // 0 / 未配置 / 非法 → 按档位自动 assert.equal(m.resolveQrSize('rows', 0), m.QR_SIZES.rows); assert.equal(m.resolveQrSize('head', 0), m.QR_SIZES.head); assert.equal(m.resolveQrSize('corner', 0), m.QR_SIZES.corner); assert.equal(m.resolveQrSize('bottom', 0), m.QR_SIZES.bottom); assert.equal(m.resolveQrSize('rows', undefined), m.QR_SIZES.rows); assert.equal(m.resolveQrSize('rows', -30), m.QR_SIZES.rows); assert.equal(m.resolveQrSize('rows', 'abc'), m.QR_SIZES.rows); // 未知档位 → rows 兜底 assert.equal(m.resolveQrSize('sideways', 0), m.QR_SIZES.rows); // 字符串数字也要认(面板可能给字符串) assert.equal(m.resolveQrSize('rows', '120'), 240); }); test('common: qrTransform 位移(任一轴非零才输出; WXSS 支持 translate)', async () => { const m = await import('../components-diy/diy-card-common.js'); assert.equal(m.qrTransform(0, 0), ''); assert.equal(m.qrTransform(undefined, undefined), ''); assert.equal(m.qrTransform(null, null), ''); assert.equal(m.qrTransform('', ''), ''); // px × 2 = rpx assert.equal(m.qrTransform(10, -20), 'translate(20rpx, -40rpx)'); assert.equal(m.qrTransform(5, 0), 'translate(10rpx, 0rpx)'); assert.equal(m.qrTransform(0, 5), 'translate(0rpx, 10rpx)'); assert.equal(m.qrTransform(-50, 50), 'translate(-100rpx, 100rpx)'); // 字符串数字(面板 number 输入可能给字符串) assert.equal(m.qrTransform('10', '20'), 'translate(20rpx, 40rpx)'); // 非数字 → 0 assert.equal(m.qrTransform('abc', 'xyz'), ''); }); // ============================================================= // 2. mini.vue 基件契约 // ============================================================= test('mini: 白卡平铺版式(2026-10-22 参考图定稿, 顶带方案已废弃)', () => { // 名片卡分支直接渲染, 不再包 mini-block/mini-band assert.match(miniSrc, / { assert.match(miniSrc, /badgeStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: '#FA5151'\s*\}\);/); }); test('mini: 头像开关(默认显示, 关掉只留姓名/职位)', () => { assert.match(miniSrc, /showAvatarBlock\(\)\s*\{\s*return flagOn\(this\.c\.showAvatar,\s*true\)/); // 模板头像块受开关控制(无 v-else 占位 —— 关掉即消失, 头部信息自然左移) assert.match(miniSrc, //); // 反例形态: 不允许出现常显头像(绕过开关) assert.ok(!miniSrc.includes(' { assert.match(miniSrc, /qrStyle\(\)\s*\{\s*return normalizeQrStyle\(this\.c\.qrStyle\)/); }); test('mini: qrBlockStyle 四档外观分支(多彩只装饰外围, 码黑白保扫)', () => { // card 白卡描边(分支内逐行收紧, 防跨分支 lazy 匹配造成假过) assert.match(miniSrc, /if \(this\.qrStyle === 'card'\) \{\s+style\.background = '#ffffff';\s+style\.border = '2rpx solid #EEF0F3';\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/); // theme 主题色衬底 assert.match(miniSrc, /else if \(this\.qrStyle === 'theme'\) \{\s+style\.background = theme;\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/); // gradient 渐变 + 6rpx 紧凑内边距; 单主题色用 darkenColor 兜底终止色(可见明暗) assert.match(miniSrc, /else if \(this\.qrStyle === 'gradient'\) \{\s+const end = \(this\.card && this\.card\.gradient_end\) \|\| darkenColor\(theme, 0\.18\);\s+style\.background = 'linear-gradient\(135deg, ' \+ theme \+ ' 0%, ' \+ end \+ ' 100%\)';\s+style\.borderRadius = '16rpx';\s+style\.padding = '6rpx';/); // plain 不进任何分支(无衬底, 即原图) —— 三个 if 之外不再设 background assert.ok(!miniSrc.includes("qrStyle === 'plain' ? style.background")); }); test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => { // qrBlockStyle + cornerClearStyle(垂直让位) + cornerHeadStyle(水平让位) 三处同口径 // (自定义大尺寸时浮层占高与占宽都要动态跟随) const hits = miniSrc.match(/resolveQrSize\(this\.qrPos, this\.c\.qrSize\)/g) || []; assert.equal(hits.length, 3, 'qrBlockStyle + cornerClearStyle + cornerHeadStyle 三处, 实际 ' + hits.length); }); test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => { assert.match(miniSrc, /const transform = qrTransform\(this\.c\.qrOffsetX, this\.c\.qrOffsetY\);\s*if \(transform\) style\.transform = transform;/); // 位移零值不输出 transform(不污染默认布局) assert.match(miniSrc, /if \(transform\) style\.transform = transform;/); }); test('mini: 扫码提示开关 + 四个挂点统一消费', () => { assert.match(miniSrc, /showQrTip\(\)\s*\{\s*return flagOn\(this\.c\.qrTipShow,\s*true\)/); // 四个二维码块(corner/head/rows/bottom)各有一个 tip, 都受开关控制 const tips = miniSrc.match(/ { 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\)/); // 衬底与 tip 参与计算(关 tip 时让位自动收窄) assert.match(miniSrc, /const tip = this\.showQrTip \? 42 : 0;/); }); test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算', () => { // 审计修复: 静态 180rpx 在自定义 qrSize>120 时不足 → 头部让位动态化 assert.match(miniSrc, /cornerHeadStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/); // 浮层宽[尺寸+衬底] + 右偏移 20 + 呼吸 20 assert.match(miniSrc, /paddingRight: \(size \+ pad \+ 40\) \+ 'rpx'/); // 模板头部绑定了动态让位(浮层不存在时输出空对象不占位) assert.match(miniSrc, //); }); test('mini: 四个二维码块统一绑 qrBlockStyle(尺寸/位移/外观单一事实来源)', () => { const binds = miniSrc.match(/:style="qrBlockStyle"/g) || []; assert.equal(binds.length, 4, 'corner/head/rows/bottom 四块, 实际 ' + binds.length); }); // ============================================================= // 3. self miniConfig 透传 / show 自绘版式二维码契约 // ============================================================= test('self: miniConfig 透传头像+二维码新六字段', () => { assert.match(selfSrc, /showAvatar:\s*v\.showAvatar/); assert.match(selfSrc, /qrStyle:\s*v\.qrStyle/); assert.match(selfSrc, /qrSize:\s*v\.qrSize/); assert.match(selfSrc, /qrOffsetX:\s*v\.qrOffsetX/); assert.match(selfSrc, /qrOffsetY:\s*v\.qrOffsetY/); assert.match(selfSrc, /qrTipShow:\s*v\.qrTipShow/); }); test('self: 「我的名片」角标已移除 + 未登录提示条(2026-10-23 参考图定稿)', () => { // 角标按 2026-10-23 参考图删除(此前 2026-10-22 曾恒显), 反例防回归 assert.ok(!selfSrc.includes('self-badge') && !selfSrc.includes('slot="badge"')); // 未登录提示条: 兜底名片下方, 左文案(点去登录)右链接(点去看看名片) assert.match(selfSrc, /v-if="!hasToken && fallbackMode !== 'none' && !loading && card"\s+class="self-login-bar"/); assert.match(selfSrc, /class="login-bar-text" :style="subTextStyle" @tap="goLogin"/); assert.match(selfSrc, /class="login-bar-link" :style="loginLinkStyle" @tap="goCardList"/); }); test('show: 头像开关(面板「显示头像」, 默认显示, 关后头部只留姓名/职位)', () => { assert.match(showSrc, /showAvatarBlock\(\)\s*\{\s*return flagOn\(this\.c\.showAvatar,\s*true\)/); assert.match(showSrc, //); // 反例形态: 不允许出现常显头像(绕过开关) assert.ok(!showSrc.includes(' { assert.match(showSrc, /showActsBlock\(\)\s*\{\s*return flagOn\(this\.c\.showQuick,\s*true\)/); assert.match(showSrc, /v-if="showActsBlock && acts\.length" class="card-acts"/); // 芯片点击直连 doAct(自绘版式不再经 $emit('act') 中转) assert.match(showSrc, /@tap\.stop="doAct\(act\)"/); }); test('show: 二维码位置四档(与面板下拉/mini 基件同口径, 未配置回落 rows)', () => { assert.match(showSrc, /qrPos\(\)\s*\{\s*return normalizeQrPosition\(this\.c\.qrPosition\)/); // 四档各自的条件渲染块 assert.match(showSrc, /showQrHead\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'head';\s*\}/); assert.match(showSrc, /showQrRows\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'rows';\s*\}/); assert.match(showSrc, /showQrCorner\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'corner';\s*\}/); assert.match(showSrc, /showQrBottom\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'bottom';\s*\}/); // 模板四个位置都有落点(头部/资料行下/浮层/主按钮上方) assert.match(showSrc, /v-if="showQrHead" class="card-qr"/); assert.match(showSrc, /v-if="showQrRows" class="qr-center"/); assert.match(showSrc, /v-if="showQrCorner" class="card-qr-corner"/); assert.match(showSrc, /v-if="showQrBottom" class="qr-center qr-center-bottom"/); // 微调位移(qrOffsetX/Y → transform) assert.match(showSrc, /qrTransform\(this\.c\.qrOffsetX,\s*this\.c\.qrOffsetY\)/); }); test('show: 封面图作背景(面板「名片设计资产 → 封面图作背景」, 默认关, 未配封面图不显示)', () => { assert.match(showSrc, /showCover\(\)\s*\{\s*return flagOn\(this\.c\.useCover,\s*false\) && !!\(this\.card && this\.card\.cover\)/); // 开启后白卡底变透明, 封面图铺满整卡 + 压暗遮罩(mini 基件同款参数) assert.match(showSrc, /background:\s*this\.showCover \? 'transparent' : \(c\.bgColor \|\| CardTokens\.color\.bg\)/); assert.match(showSrc, //); assert.match(showSrc, /<\/view>/); assert.match(showSrc, /background: rgba\(15, 23, 42, 0\.45\);/); }); test('show: 主题渐变饰条(面板「名片设计资产 → 主题渐变饰条」, 默认关)', () => { assert.match(showSrc, /showGradBar\(\)\s*\{\s*return flagOn\(this\.c\.showGradient,\s*false\)/); // 饰条底色走名片主题渐变 assert.match(showSrc, /gradBarStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: themeGradient\(this\.card\),\s*\}\);/); // 模板: 卡片顶部横条 + 细高圆角样式 assert.match(showSrc, /<\/view>/); assert.match(showSrc, /\.card-grad-bar \{[\s\S]*?height: 14rpx;[\s\S]*?border-radius: 8rpx;/); }); test('show: 名片 Logo 开关(面板「名片设计资产 → 名片 Logo」, 默认关; 未配 Logo 不显示; 头部档二维码同位避让)', () => { assert.match(showSrc, /showLogo\(\)\s*\{\s*return flagOn\(this\.c\.showLogo,\s*false\) && !!\(this\.card && this\.card\.logo\) && !this\.showQrHead;/); // 模板: 头部右侧 Logo 图 assert.match(showSrc, //); // 样式: 头部右侧小图, margin-left:auto 推到行尾 assert.match(showSrc, /\.card-logo \{[\s\S]*?width: 96rpx;[\s\S]*?margin-left: auto;/); }); test('show: 资料行图标开关(面板「名片信息展示 → 资料行图标」, 默认开; 关后退化文字标签)', () => { // 与 mini 基件 rowIcon 同口径: 开关 + 字段字形双重判定 assert.match(showSrc, /rowIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\) \? rowIconFor\(row && row\.field\) : '';/); // 模板: 有图标显图标, 无图标退化文字标签 assert.match(showSrc, /<\/text>/); assert.match(showSrc, /\{\{ row\.label \}\}<\/text>/); }); test('show: 发名片按钮开关(面板「底部按钮 → 发送名片按钮」, 默认显示; 文案消费 detailBtnText)', () => { assert.match(showSrc, /showSendBtn\(\)\s*\{\s*return flagOn\(this\.c\.showDetailBtn,\s*true\)/); assert.match(showSrc, /v-if="showSendBtn" class="show-send"/); // 点击弹分享抽屉(与二维码块同款 openCardShare, 2026-10-22 起) assert.match(showSrc, /@tap="openCardShare">\s*/); assert.ok(!showSrc.includes('onSend')); // 按钮文案: 面板「按钮文案」可配, 默认「发送名片」 assert.match(showSrc, /sendBtnText\(\)\s*\{\s*return this\.c\.detailBtnText \|\| '发送名片';/); assert.match(showSrc, /\{\{ sendBtnText \}\}<\/text>/); // 反例形态: 不允许硬编码文案(绕过面板配置) assert.ok(!showSrc.includes('发名片')); }); test('show: 二维码外观四档(plain 原图 / card 白卡描边 / theme 主题色 / gradient 渐变, 与 mini 同口径)', () => { assert.match(showSrc, /qrStyle\(\)\s*\{\s*return normalizeQrStyle\(this\.c\.qrStyle\)/); // 四个分支逐行收紧, 防跨分支 lazy 匹配造成假过(与 mini qrBlockStyle 同款值) assert.match(showSrc, /if \(this\.qrStyle === 'card'\) \{\s+style\.background = '#ffffff';\s+style\.border = '2rpx solid #EEF0F3';\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/); assert.match(showSrc, /else if \(this\.qrStyle === 'theme'\) \{\s+style\.background = theme;\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/); // gradient 分支: 单主题色时也要可见明暗渐变(用 darkenColor 兜底终止色), 不能退化成纯色 assert.match(showSrc, /else if \(this\.qrStyle === 'gradient'\) \{\s+const theme = \(this\.card && this\.card\.theme_color\) \|\| CardTokens\.color\.primary;\s+const end = \(this\.card && this\.card\.gradient_end\) \|\| darkenColor\(theme, 0\.18\);\s+style\.background = 'linear-gradient\(135deg, ' \+ theme \+ ' 0%, ' \+ end \+ ' 100%\)';\s+style\.borderRadius = '16rpx';\s+style\.padding = '6rpx';/); // plain 不进任何分支(无衬底, 即原图) assert.ok(!showSrc.includes("qrStyle === 'plain' ? style.background")); // 四个位置档共用 qrBlockStyle(单字符串绑定) assert.equal((showSrc.match(/:style="qrBlockStyle"/g) || []).length, 4); // 反例: uni-app 运行时静默丢弃 :style="[字符串, 字符串]" 数组绑定 // (2026-10-22 线上问题: 微调/外观在 C 端全部失效的根因), 禁止回归 assert.ok(!showSrc.includes(':style="[qrBlockStyle')); // 微调位移并入 qrBlockStyle 同一对象(qrTransform 引用仍在计算属性内) assert.match(showSrc, /const t = qrTransform\(this\.c\.qrOffsetX,\s*this\.c\.qrOffsetY\);\s*if \(t\) style\.transform = t;/); }); test('show: 自绘版式自带小二维码(开关可关 + 点击进详情, 尺寸/提示文字面板可调)', () => { assert.match(showSrc, /class="card-qr"/); assert.match(showSrc, /\$util\.img\(card\.qr_url\)/); // 二维码块受面板「显示二维码」开关控制(与头像/资料行/快捷操作同口径: flagOn 默认显示) assert.match(showSrc, /showQrBlock\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*true\)/); // 开关 + 位置统一收敛在 showQrBase / showQrHead 等分档计算属性里(模板只消费分档结果) assert.match(showSrc, /showQrBase\(\)\s*\{\s*return this\.showQrBlock && !!\(this\.card && this\.card\.qr_url\)/); // 提示文字消费面板 qrTip, 未配置回落「扫码查看完整名片」(与 mini 基件同口径) assert.match(showSrc, /qrTipText\(\)\s*\{\s*return this\.c\.qrTip \|\| '扫码查看完整名片';/); // 点二维码弹「分享名片」抽屉(复刻 detail.vue: 码 + 链接 + 复制链接/浏览器分享/保存二维码) assert.match(showSrc, /@tap\.stop="openCardShare"/); assert.match(showSrc, /v-if="showShare" @tap="closeShare"/); assert.match(showSrc, /class="cs-sheet" :class="\{ 'cs-sheet-show': showShare \}"/); assert.match(showSrc, /分享名片<\/view>/); assert.match(showSrc, /客户打开链接即可查看名片并留言留资/); assert.match(showSrc, /copyCardLink\(\)\s*\{\s*const url = \(this\.card && this\.card\.wap_url\) \|\| '';/); // 尺寸走 resolveQrSize: 面板 qrSize>0 用自定义, 0/空 = 按位置档位自动(rows/bottom 180, head/corner 120)。 // 反例形态: 不允许 num(qrSize, 48) 兜底 —— 面板默认 0 是合法数字, 会算出 0rpx 宽高, // 二维码图渲染成 0 尺寸「看不见」(2026-10-21 线上问题) assert.match(showSrc, /qrSide\(\)\s*\{\s*return resolveQrSize\(this\.qrPos,\s*this\.c\.qrSize\)/); assert.ok(!showSrc.includes("num(this.c.qrSize")); }); // ============================================================= // 4. design.html 面板: 控件绑定 + 预览 mock 消费 // ============================================================= for (const [name, panel] of hasPanels ? [['show', showPanel], ['self', selfPanel]] : []) { test(name + ' 面板: 头像开关 + 二维码新六字段有 v-model 绑定', () => { assert.match(panel, /v-model="nc\.showAvatar"/); assert.match(panel, /v-model="nc\.qrStyle"/); assert.match(panel, /v-model\.number="nc\.qrSize"/); assert.match(panel, /v-model\.number="nc\.qrOffsetX"/); 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('