- 组件支持六个「可独立成块」模块任意排序(区块流): 资料行/快捷操作/社交/二维码/ 标签来源的位置可调, 头部恒最上、发名片恒最下; 二维码位置档位归并为 flow。 - 补齐「名片基础信息」遗漏字段: 简介区块、社交区块、名片附加信息(过期遮罩/ 地址细分/来源), 并与 wap 端展示口径对齐。 - 小程序分享给游客时携带名片 ID: 分享 path 追加 card_id, 访客打开「我的名片」 即看到分享者那张卡。新增 common/js/card-share.js 统一处理「取卡ID / 拼参数」 (dataset 通道 + storage 通道兜底, 朋友圈场景靠 storage)。
325 lines
15 KiB
JavaScript
325 lines
15 KiB
JavaScript
/**
|
||
* 名片 DIY 组件 · 名片级自定义二维码(card.qr_image)契约测试
|
||
*
|
||
* 背景(2.20):
|
||
* 此前二维码只有两个来源 —— 组件级自定义图(c.qrImage)与系统生成码(card.qr_url)。
|
||
* 2.20 起每张名片(员工名片/企业名片)可各自持有一张自定义二维码图片,
|
||
* DIY 组件按三层优先级取用:
|
||
* 1) 名片级 card.qr_image ← 每张名片各自一张(「我的名片」登录后即见本人那张)
|
||
* 2) 组件级 c.qrImage ← 装修期统一指定(名片自己没设图时生效)
|
||
* 3) 系统码 card.qr_url ← 兜底(原有行为)
|
||
*
|
||
* 覆盖:
|
||
* 1. mini 基件 qrSrc 按上述优先级取值(源码契约 + 真实求值双证)
|
||
* 2. 组件级 qrImage 仍完整保留(未被名片级顶掉)
|
||
* 3. 反例: 虚构字段不得出现(防恒真)
|
||
*
|
||
* 跑法: node --test tests/diy-card-qr-image-source.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');
|
||
// 先剥注释再断言 —— 注释里写「绝不能写 X」会被自判违规
|
||
const strip = s => s
|
||
.replace(/<!--[\s\S]*?-->/g, '')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||
|
||
const miniSrc = strip(read('diy-card-mini.vue'));
|
||
|
||
/**
|
||
* 从 mini.vue 抽出 qrSrc 的真实函数体并求值 —— 不靠正则猜逻辑,
|
||
* 避免「正则匹配到注释/字符串就假绿」。
|
||
*
|
||
* 函数体内部用的是 this.c / this.card, 故以 ctx 为 this 求值。
|
||
*/
|
||
function extractQrSrc(src) {
|
||
const m = src.match(/qrSrc\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||
assert.ok(m, 'mini.vue 找不到 qrSrc 计算属性');
|
||
return m[1];
|
||
}
|
||
|
||
/** 用构造器求值 qrSrc 函数体(只依赖 card/c 两个入参) */
|
||
function evalQrSrc(card, config) {
|
||
const body = extractQrSrc(miniSrc);
|
||
// eslint-disable-next-line no-new-func
|
||
const fn = new Function('card', 'c', body);
|
||
// ctx 需提供 qrPriority 计算属性(Vue 里 qrSrc 依赖 this.qrPriority)
|
||
const ctx = {
|
||
card: card,
|
||
c: config,
|
||
get qrPriority() { return evalQrPriority(config); },
|
||
};
|
||
return fn.call(ctx, card, config);
|
||
}
|
||
|
||
/** 抽 qrPriority 计算属性体并求值(模式白名单化) */
|
||
function evalQrPriority(config) {
|
||
const m = miniSrc.match(/qrPriority\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||
assert.ok(m, 'mini.vue 找不到 qrPriority 计算属性');
|
||
// eslint-disable-next-line no-new-func
|
||
const fn = new Function('c', m[1]);
|
||
const ctx = { c: config };
|
||
return fn.call(ctx, config);
|
||
}
|
||
|
||
test('mini: qrSrc 源码按「名片级 > 组件级 > 系统码」取用', () => {
|
||
const body = extractQrSrc(miniSrc);
|
||
// 名片级: card.qr_image
|
||
assert.ok(/card\.qr_image/.test(body), 'qrSrc 未消费名片级 card.qr_image');
|
||
// 组件级: c.qrImage
|
||
assert.ok(/c\.qrImage/.test(body), 'qrSrc 未保留组件级 c.qrImage');
|
||
// 系统码兜底: card.qr_url
|
||
assert.ok(/card\.qr_url/.test(body), 'qrSrc 未保留系统码兜底 card.qr_url');
|
||
});
|
||
|
||
test('mini: qrSrc 真实求值 —— 名片级优先于组件级', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png' }
|
||
);
|
||
assert.equal(got, '/upload/own.png');
|
||
});
|
||
|
||
test('mini: qrSrc 真实求值 —— 名片级为空时回落组件级', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png' }
|
||
);
|
||
assert.equal(got, '/upload/component.png');
|
||
});
|
||
|
||
test('mini: qrSrc 真实求值 —— 两级皆空回落系统生成码(原行为)', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '', qr_url: 'http://x/q' },
|
||
{ qrImage: '' }
|
||
);
|
||
assert.equal(got, 'http://x/q');
|
||
});
|
||
|
||
test('mini: qrSrc 真实求值 —— 名片级优先(组件级也配了图时)', () => {
|
||
// 「我的名片」组件: 员工登录后应看到属于该员工自己名片的那张图,
|
||
// 即使装修时组件级也配了图 —— 名片级更贴近实体, 优先。
|
||
const got = evalQrSrc(
|
||
{ qr_image: '/upload/employee-own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/shop-level.png' }
|
||
);
|
||
assert.equal(got, '/upload/employee-own.png');
|
||
});
|
||
|
||
test('mini: qrSrc 真实求值 —— card 为 null 不抛异常(未登录空态)', () => {
|
||
const got = evalQrSrc(null, { qrImage: '/upload/component.png' });
|
||
assert.equal(got, '/upload/component.png');
|
||
});
|
||
|
||
// =============================================================
|
||
// 2.20.1 优先级模式(用户在 DIY 组件面板显式选择)
|
||
// =============================================================
|
||
|
||
test('mini: qrPriority 白名单化 —— 缺失/非法值回落 card', () => {
|
||
assert.equal(evalQrPriority({}), 'card');
|
||
assert.equal(evalQrPriority({ qrPriority: '' }), 'card');
|
||
assert.equal(evalQrPriority({ qrPriority: 'evil' }), 'card');
|
||
assert.equal(evalQrPriority({ qrPriority: 'component' }), 'component');
|
||
assert.equal(evalQrPriority({ qrPriority: 'system' }), 'system');
|
||
});
|
||
|
||
test('mini: mode=card 名片级优先(默认; 员工登录看到自己那张)', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||
);
|
||
assert.equal(got, '/upload/own.png');
|
||
});
|
||
|
||
test('mini: mode=card 名片级为空则用组件级', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||
);
|
||
assert.equal(got, '/upload/component.png');
|
||
});
|
||
|
||
test('mini: mode=component 组件级优先于名片级', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png', qrPriority: 'component' }
|
||
);
|
||
assert.equal(got, '/upload/component.png');
|
||
});
|
||
|
||
test('mini: mode=component 组件级为空则回落名片级', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '', qrPriority: 'component' }
|
||
);
|
||
assert.equal(got, '/upload/own.png');
|
||
});
|
||
|
||
test('mini: mode=system 只用系统生成码(忽略全部自定义图)', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png', qrPriority: 'system' }
|
||
);
|
||
assert.equal(got, 'http://x/q');
|
||
});
|
||
|
||
test('mini: mode=system 名片级有图也忽略', () => {
|
||
const got = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '', qrPriority: 'system' }
|
||
);
|
||
assert.equal(got, 'http://x/q');
|
||
});
|
||
|
||
test('mini: 未设置 qrPriority(存量实例)等价于 card', () => {
|
||
const withMode = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||
);
|
||
const withoutMode = evalQrSrc(
|
||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||
{ qrImage: '/upload/component.png' }
|
||
);
|
||
assert.equal(withoutMode, withMode);
|
||
});
|
||
|
||
// =============================================================
|
||
// 面板契约(两个组件的 design.html 都要有来源优先级控件)
|
||
// =============================================================
|
||
|
||
const PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
|
||
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
|
||
|
||
if (hasPanels) {
|
||
const selfPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8'));
|
||
const showPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8'));
|
||
|
||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||
test('面板(' + name + '): 二维码来源优先级三档控件均存在', () => {
|
||
for (const v of ['card', 'component', 'system']) {
|
||
assert.ok(
|
||
panel.includes("nc.qrPriority = '" + v + "'"),
|
||
name + ' 面板缺优先级选项 ' + v
|
||
);
|
||
}
|
||
});
|
||
|
||
test('面板(' + name + '): 来源控件在「显示二维码」打开时才显示', () => {
|
||
// 控件所在容器必须带 v-show="nc.showQr"
|
||
const idx = panel.indexOf("nc.qrPriority = 'card'");
|
||
assert.ok(idx > 0, name + ' 面板找不到来源控件');
|
||
const head = panel.slice(Math.max(0, idx - 1200), idx);
|
||
assert.ok(head.includes('v-show="nc.showQr"'), name + ' 面板来源控件未随显示二维码开关联动');
|
||
});
|
||
}
|
||
|
||
test('面板: 优先级默认值与 config 注册表一致(card)', () => {
|
||
// config/diy_view.php 的 util 段默认值
|
||
const cfgPath = path.resolve(__dirname, '../../backend/src/addon/card/config/diy_view.php');
|
||
const cfg = fs.readFileSync(cfgPath, 'utf8');
|
||
assert.ok(cfg.includes('"qrPriority":"card"'), 'config/diy_view.php 缺 qrPriority 默认值');
|
||
});
|
||
|
||
test('面板: 预览真实消费 qrPriority(3 处二维码挂点走分层 v-if)', () => {
|
||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||
// 2.23: 挂点由 4(head/corner/rows/bottom)合并为 3(head/corner/卡片流内) 各一组分支:
|
||
// system 真实系统码 + component 组件图 + component 名片图 + card 名片图 + card 组件图 + 兜底真实系统码
|
||
// ⚠ 计数只数**渲染分支**(v-if/v-else-if 挂在 .diy-card-qr__img 上的),
|
||
// 「当前实际生效」提示块里也有 nc.qrPriority === 'system' 等判定, 不能计入。
|
||
const sys = (panel.match(/class="diy-card-qr__img" v-if="nc\.qrPriority === 'system'"/g) || []).length;
|
||
const comp = (panel.match(/class="diy-card-qr__img diy-card-qr__img--custom" v-else-if="nc\.qrPriority === 'component'/g) || []).length;
|
||
assert.equal(sys, 3, name + ' 面板 system 档分支数不对: ' + sys);
|
||
// component 档每挂点 2 支(组件图 + 名片图回落) = 6
|
||
assert.equal(comp, 6, name + ' 面板 component 档分支数不对: ' + comp);
|
||
// card 档每挂点 2 支(名片图 + 组件图回落) = 6
|
||
const card = (panel.match(/class="diy-card-qr__img diy-card-qr__img--custom" v-else-if="nc\.qrPriority === 'card' &&/g) || []).length;
|
||
assert.equal(card, 6, name + ' 面板 card 档分支数不对: ' + card);
|
||
// 名片级图分支(每挂点 2 支: component 回落 + card 优先;
|
||
// 每支出现 2 次: v-else-if 条件 + style 内取值) = 12
|
||
// + 「当前实际生效」提示块内 2 次 = 14
|
||
const cardImg = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_image/g) || []).length;
|
||
assert.equal(cardImg, 14, name + ' 面板名片级图分支数不对: ' + cardImg);
|
||
// system 档与兜底档必须渲染真实系统码 qr_url(否则看不出真实出码方式)
|
||
// 2.23: 3 挂点 × (system 档 + 兜底档) = 6
|
||
const qrUrl = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_url/g) || []).length;
|
||
assert.equal(qrUrl, 6, name + ' 面板系统码 URL 分支数不对: ' + qrUrl);
|
||
}
|
||
});
|
||
|
||
test('面板: 有「当前实际生效」提示(切三档时商家能看到结果差异)', () => {
|
||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||
assert.ok(panel.includes('当前实际生效'), name + ' 面板缺当前生效来源提示');
|
||
assert.ok(panel.includes('(微信小程序码)'), name + ' 面板提示未区分小程序码');
|
||
assert.ok(panel.includes('(普通二维码)'), name + ' 面板提示未区分普通码');
|
||
// 三档各自独立判定
|
||
for (const mode of ['system', 'component', 'card']) {
|
||
assert.ok(
|
||
panel.includes("nc.qrPriority === '" + mode + "'"),
|
||
name + ' 面板提示缺 ' + mode + ' 档判定'
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('面板: 预览不得调用 window 全局函数(Vue2 模板作用域不可见)', () => {
|
||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||
assert.ok(
|
||
!/cardQrPreviewSrc\s*\(/.test(panel),
|
||
name + ' 面板仍调用 window 全局函数 —— Vue2 模板会报「not defined on the instance」'
|
||
);
|
||
}
|
||
});
|
||
|
||
// 2026-10-27: 「快捷操作管理」图层行的 color input 空值报错
|
||
// 图层行 v-for 数组曾只有 {k,ch}(无 bg/fg), 而同行 :value 引用 a.fg/a.bg
|
||
// → undefined → Vue 写 elm.value='' → Chrome CSS color 格式告警
|
||
test('面板: 图层行 v-for 数组含 bg/fg(避免 color input 空值)', () => {
|
||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||
const m = panel.match(/v-for="a in (\[\{k:'tel'[\s\S]*?\}\])"/);
|
||
assert.ok(m, name + ' 面板找不到图层行 v-for 数组');
|
||
const arr = m[1];
|
||
const items = arr.match(/\{k:'[a-z]+'/g) || [];
|
||
assert.equal(items.length, 5, name + ' 面板图层行数组项数不对: ' + items.length);
|
||
const bgCount = (arr.match(/bg:'#[0-9A-Fa-f]{6}'/g) || []).length;
|
||
const fgCount = (arr.match(/fg:'#[0-9A-Fa-f]{6}'/g) || []).length;
|
||
assert.equal(bgCount, 5, name + ' 面板图层行数组缺 bg: ' + bgCount);
|
||
assert.equal(fgCount, 5, name + ' 面板图层行数组缺 fg: ' + fgCount);
|
||
}
|
||
});
|
||
|
||
test('面板: 图层行 color input 的 :value 不含未定义引用', () => {
|
||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||
const colorBind = panel.match(/data-field="color"[^>]*:value="([^"]*)"/);
|
||
const bgBind = panel.match(/data-field="bg"[^>]*:value="([^"]*)"/);
|
||
assert.ok(colorBind, name + ' 面板找不到图层行 color 绑定');
|
||
assert.ok(bgBind, name + ' 面板找不到图层行 bg 绑定');
|
||
for (const [label, expr] of [['color', colorBind[1]], ['bg', bgBind[1]]]) {
|
||
const refs = (expr.match(/\ba\.([a-z]+)/g) || []).map(x => x.slice(2));
|
||
for (const r of Array.from(new Set(refs))) {
|
||
assert.ok(
|
||
['k', 'ch', 'lb', 'bg', 'fg'].includes(r),
|
||
name + ' 面板图层行 ' + label + ' 绑定引用了数组里不存在的 a.' + r
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// =============================================================
|
||
// 反例(防恒真)
|
||
// =============================================================
|
||
|
||
test('反例: 虚构字段绝不出现在基件', () => {
|
||
assert.ok(!miniSrc.includes('c.qrMagic'));
|
||
assert.ok(!miniSrc.includes('card.qrMagic'));
|
||
});
|