- 组件支持六个「可独立成块」模块任意排序(区块流): 资料行/快捷操作/社交/二维码/ 标签来源的位置可调, 头部恒最上、发名片恒最下; 二维码位置档位归并为 flow。 - 补齐「名片基础信息」遗漏字段: 简介区块、社交区块、名片附加信息(过期遮罩/ 地址细分/来源), 并与 wap 端展示口径对齐。 - 小程序分享给游客时携带名片 ID: 分享 path 追加 card_id, 访客打开「我的名片」 即看到分享者那张卡。新增 common/js/card-share.js 统一处理「取卡ID / 拼参数」 (dataset 通道 + storage 通道兜底, 朋友圈场景靠 storage)。
330 lines
14 KiB
JavaScript
330 lines
14 KiB
JavaScript
/**
|
|
* 名片分享带卡 ID · 前端契约测试(2026-10-28)
|
|
*
|
|
* 背景:
|
|
* 小程序端「我的名片」组件(diy-card-self)的「发送名片」按钮是 <button open-type="share">,
|
|
* 微信只认**页面级** onShareAppMessage。承载该组件的 DIY 页自带 onShareAppMessage
|
|
* (common/js/diy.js) → Vue2 defaultStrat(子覆盖父)会顶掉全局兜底 mixin
|
|
* (common/js/mp-share-default.js)里那段 dataset.cardId 分支
|
|
* → 分享 path 不带 card_id, 访客打开看到的是企业兜底卡而不是分享者那张卡。
|
|
*
|
|
* 修法(前端侧):
|
|
* ① common/js/card-share.js(新增): 三条取 card_id 的通道
|
|
* dataset(发送给朋友) / storage(朋友圈) / 路由 query(落地回传)
|
|
* ② common/js/diy.js: 页面级分享钩子把 card_id 拼进分享 path
|
|
* ③ components-diy/diy-card-mini.vue: 分享按钮 @tap 记住当前卡片(storage 通道)
|
|
* ④ components-diy/diy-card-self.vue: 从路由 query 取 card_id 回传 /card/api/diy/mycard
|
|
*
|
|
* 跑法: node --test tests/card-share-card-id.test.js
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
const strip = s => s
|
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
|
|
/**
|
|
* card-share.js 是纯 ESM(无 import) —— 去掉 export 关键字后用 new Function 求值,
|
|
* 直接跑真实逻辑(不复制源码, 避免测试与实现漂移)。
|
|
* uni / getCurrentPages 以参数注入, 天然完成 mock。
|
|
*/
|
|
function loadCardShare(uni, getCurrentPages) {
|
|
const src = read('common/js/card-share.js').replace(/export\s+(const|function)\s/g, '$1 ');
|
|
const factory = new Function(
|
|
'uni', 'getCurrentPages',
|
|
src + `
|
|
return {
|
|
SHARE_CARD_STORAGE_KEY, SHARE_CARD_MEMORY_TTL,
|
|
rememberShareCardId, takeRememberedShareCardId, pickShareCardId,
|
|
routeQueryValue, appendPathParam, appendQueryParam, pathQueryPart
|
|
};`
|
|
);
|
|
return factory(uni, getCurrentPages);
|
|
}
|
|
|
|
function makeUni() {
|
|
const store = new Map();
|
|
return {
|
|
store,
|
|
setStorageSync(k, v) { store.set(k, v); },
|
|
getStorageSync(k) { return store.has(k) ? store.get(k) : ''; },
|
|
removeStorageSync(k) { store.delete(k); },
|
|
};
|
|
}
|
|
|
|
// =============================================================
|
|
// A. card-share.js 真实行为
|
|
// =============================================================
|
|
|
|
test('A1: 记住 → 取出 往返一致', () => {
|
|
const uni = makeUni();
|
|
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
|
assert.equal(rememberShareCardId('C_1_shop_20261013111612_9588EA'), 'C_1_shop_20261013111612_9588EA');
|
|
assert.equal(takeRememberedShareCardId(), 'C_1_shop_20261013111612_9588EA');
|
|
});
|
|
|
|
test('A2: 取出后即清除(下一次分享不串卡)', () => {
|
|
const uni = makeUni();
|
|
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
|
rememberShareCardId('C_A');
|
|
assert.equal(takeRememberedShareCardId(), 'C_A');
|
|
assert.equal(takeRememberedShareCardId(), '', '第二次取应为空');
|
|
});
|
|
|
|
test('A3: 空 card_id 不入记忆槽', () => {
|
|
const uni = makeUni();
|
|
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
|
assert.equal(rememberShareCardId(''), '');
|
|
assert.equal(rememberShareCardId(null), '');
|
|
assert.equal(takeRememberedShareCardId(), '');
|
|
});
|
|
|
|
test('A4: 记忆过期则不再用于分享(TTL)', () => {
|
|
const uni = makeUni();
|
|
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
|
rememberShareCardId('C_OLD');
|
|
// 把时间戳改老(10 分钟前)
|
|
uni.store.set('_cardShareCardId', { card_id: 'C_OLD', at: Date.now() - 10 * 60 * 1000 });
|
|
assert.equal(takeRememberedShareCardId(), '', '过期记忆应被丢弃');
|
|
});
|
|
|
|
test('A5: pickShareCardId 优先取 dataset(发送给朋友)', () => {
|
|
const uni = makeUni();
|
|
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
|
|
rememberShareCardId('C_FROM_STORAGE');
|
|
const opts = { from: 'button', target: { dataset: { cardId: 'C_FROM_DATASET' } } };
|
|
assert.equal(pickShareCardId(opts), 'C_FROM_DATASET');
|
|
});
|
|
|
|
test('A6: pickShareCardId 无 dataset 时回落 storage(朋友圈通道)', () => {
|
|
const uni = makeUni();
|
|
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
|
|
rememberShareCardId('C_FROM_STORAGE');
|
|
assert.equal(pickShareCardId(null), 'C_FROM_STORAGE');
|
|
// storage 通道是"读一次即清"语义 → 第二次取前需重新记忆
|
|
rememberShareCardId('C_FROM_STORAGE');
|
|
assert.equal(pickShareCardId({ from: 'menu' }), 'C_FROM_STORAGE');
|
|
});
|
|
|
|
test('A7: pickShareCardId 两路都没有 → 空', () => {
|
|
const uni = makeUni();
|
|
const { pickShareCardId } = loadCardShare(uni, () => []);
|
|
assert.equal(pickShareCardId({ from: 'menu' }), '');
|
|
assert.equal(pickShareCardId(null), '');
|
|
});
|
|
|
|
test('A8: routeQueryValue 从当前页 options 取值(分享落地页)', () => {
|
|
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
|
|
route: 'pages_tool/index/diy',
|
|
options: { id: '123', name: 'home', card_id: 'C_1_shop_x' },
|
|
}]));
|
|
assert.equal(routeQueryValue('card_id'), 'C_1_shop_x');
|
|
assert.equal(routeQueryValue('id'), '123');
|
|
assert.equal(routeQueryValue('not_exist'), '');
|
|
});
|
|
|
|
test('A9: routeQueryValue 兼容字符串形态 query', () => {
|
|
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
|
|
route: 'pages_tool/index/diy',
|
|
query: 'id=123&card_id=C_2',
|
|
}]));
|
|
assert.equal(routeQueryValue('card_id'), 'C_2');
|
|
});
|
|
|
|
test('A10: routeQueryValue 无页面 / 无 uni 时安全返回空', () => {
|
|
const { routeQueryValue } = loadCardShare(makeUni(), () => []);
|
|
assert.equal(routeQueryValue('card_id'), '');
|
|
});
|
|
|
|
test('A11: appendPathParam 拼 path(带/不带 query 两种形态)', () => {
|
|
const { appendPathParam } = loadCardShare(makeUni(), () => []);
|
|
assert.equal(
|
|
appendPathParam('/pages_tool/index/diy?id=123', 'card_id', 'C_1'),
|
|
'/pages_tool/index/diy?id=123&card_id=C_1'
|
|
);
|
|
assert.equal(
|
|
appendPathParam('/pages_tool/index/diy', 'card_id', 'C_1'),
|
|
'/pages_tool/index/diy?card_id=C_1'
|
|
);
|
|
assert.equal(appendPathParam('/pages_tool/index/diy?id=1', 'card_id', ''), '/pages_tool/index/diy?id=1');
|
|
});
|
|
|
|
test('A12: appendQueryParam 拼 query(朋友圈只认 query)', () => {
|
|
const { appendQueryParam } = loadCardShare(makeUni(), () => []);
|
|
assert.equal(appendQueryParam('id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
|
|
assert.equal(appendQueryParam('', 'card_id', 'C_1'), 'card_id=C_1');
|
|
assert.equal(appendQueryParam('?id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
|
|
});
|
|
|
|
test('A13: pathQueryPart 取 path 的 query 段', () => {
|
|
const { pathQueryPart } = loadCardShare(makeUni(), () => []);
|
|
assert.equal(pathQueryPart('/pages_tool/index/diy?id=1&name=x'), 'id=1&name=x');
|
|
assert.equal(pathQueryPart('/pages_tool/index/diy'), '');
|
|
});
|
|
|
|
// =============================================================
|
|
// B. DIY 页分享钩子(common/js/diy.js)
|
|
// =============================================================
|
|
|
|
/**
|
|
* 把 diy.js 的两个分享钩子抠出来**真跑**(diy.js 本体 import 了地图/配置等模块,
|
|
* node 里无法直接 import; 这里只抠钩子源码, 用 card-share.js 的真实实现注入)。
|
|
*/
|
|
function loadDiyShareHooks() {
|
|
const src = read('common/js/diy.js');
|
|
const start = src.indexOf('onShareAppMessage(options) {');
|
|
// 两个钩子是文件最后两个方法 → 一直取到导出对象的最后一个 '}' 之前
|
|
const end = src.lastIndexOf('}');
|
|
assert.ok(start > 0 && end > start, '未能定位 diy.js 的分享钩子');
|
|
const hooksSrc = src.slice(start, end);
|
|
const share = loadCardShare(makeUni(), () => []);
|
|
const factory = new Function(
|
|
'pickShareCardId', 'appendPathParam', 'appendQueryParam', 'pathQueryPart',
|
|
'return {' + hooksSrc + '};'
|
|
);
|
|
const hooks = factory(share.pickShareCardId, share.appendPathParam, share.appendQueryParam, share.pathQueryPart);
|
|
return { hooks, share };
|
|
}
|
|
|
|
/** 模拟一个 DIY 页实例的上下文 */
|
|
function makeDiyCtx() {
|
|
return {
|
|
mpShareData: {
|
|
appMessage: { title: '店铺名', path: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
|
|
timeLine: { title: '店铺名', query: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
|
|
},
|
|
$util: {
|
|
getCurrentRoute: () => ({ path: '/pages_tool/index/diy?id=3&name=home', query: 'id=3&name=home' }),
|
|
},
|
|
};
|
|
}
|
|
|
|
test('B1: diy.js 引入名片分享工具', () => {
|
|
const src = read('common/js/diy.js');
|
|
assert.match(src, /import\s*\{[^}]*pickShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
|
|
'未引入 card-share.js 的 pickShareCardId');
|
|
});
|
|
|
|
test('B2: onShareAppMessage 接 options 并按 dataset/storage 取 card_id', () => {
|
|
const src = strip(read('common/js/diy.js'));
|
|
assert.match(src, /onShareAppMessage\(options\)\s*\{/,
|
|
'onShareAppMessage 未接事件参数(拿不到 dataset)');
|
|
assert.match(src, /pickShareCardId\(options\)/,
|
|
'onShareAppMessage 未从事件里取 card_id');
|
|
});
|
|
|
|
test('B3: onShareAppMessage 把 card_id 拼进分享 path', () => {
|
|
const src = strip(read('common/js/diy.js'));
|
|
assert.match(src, /appendPathParam\([^)]*'card_id',\s*cardId\)/,
|
|
'分享 path 未追加 card_id');
|
|
});
|
|
|
|
test('B4: onShareTimeline 走 storage 通道并把 card_id 拼进 query', () => {
|
|
const src = strip(read('common/js/diy.js'));
|
|
assert.match(src, /onShareTimeline\(\)\s*\{/);
|
|
assert.match(src, /pickShareCardId\(null\)/,
|
|
'onShareTimeline 未从 storage 取 card_id(它不接受事件参数)');
|
|
assert.match(src, /appendQueryParam\([^)]*'card_id',\s*cardId\)/,
|
|
'朋友圈 query 未追加 card_id');
|
|
});
|
|
|
|
test('B5: 拿不到 card_id 时仍回落原分享数据(向后兼容)', () => {
|
|
const src = strip(read('common/js/diy.js'));
|
|
assert.match(src, /return this\.mpShareData\?\.appMessage;/,
|
|
'未保留原有 appMessage 回落');
|
|
assert.match(src, /return base;/,
|
|
'未保留原有 timeLine 回落');
|
|
});
|
|
|
|
test('B6: 行为级 — 钩子真跑: dataset 命中 → path 带 card_id', () => {
|
|
const { hooks, share } = loadDiyShareHooks();
|
|
const ctx = makeDiyCtx();
|
|
const res = hooks.onShareAppMessage.call(ctx, {
|
|
from: 'button',
|
|
target: { dataset: { cardId: 'C_1_shop_x', cardName: '张三' } },
|
|
});
|
|
assert.equal(res.path, '/pages_tool/index/diy?id=3&name=home&card_id=C_1_shop_x',
|
|
'分享 path 未带上当前页 query + card_id');
|
|
assert.equal(res.title, '店铺名');
|
|
});
|
|
|
|
test('B7: 行为级 — 无 card_id 时原样返回后台配置的分享数据', () => {
|
|
const { hooks } = loadDiyShareHooks();
|
|
const ctx = makeDiyCtx();
|
|
assert.equal(hooks.onShareAppMessage.call(ctx, { from: 'menu' }), ctx.mpShareData.appMessage,
|
|
'无 card_id 时应原样返回 mpShareData.appMessage');
|
|
});
|
|
|
|
test('B8: 行为级 — 朋友圈钩子把 card_id 拼进 query', () => {
|
|
const { hooks, share } = loadDiyShareHooks();
|
|
const ctx = makeDiyCtx();
|
|
share.rememberShareCardId('C_1_shop_x');
|
|
const res = hooks.onShareTimeline.call(ctx);
|
|
assert.equal(res.query, 'id=3&card_id=C_1_shop_x',
|
|
'朋友圈 query 未带 card_id');
|
|
assert.equal(res.title, '店铺名');
|
|
});
|
|
|
|
test('B9: 行为级 — 朋友圈无 card_id 时原样回落', () => {
|
|
const { hooks } = loadDiyShareHooks();
|
|
const ctx = makeDiyCtx();
|
|
assert.equal(hooks.onShareTimeline.call(ctx), ctx.mpShareData.timeLine);
|
|
});
|
|
|
|
// =============================================================
|
|
// C. 名片基件的分享按钮(diy-card-mini.vue)
|
|
// =============================================================
|
|
|
|
test('C1: 小程序端分享按钮带 data-card-id + @tap 记忆', () => {
|
|
const src = strip(read('components-diy/diy-card-mini.vue'));
|
|
assert.match(src, /open-type="share"/, '分享按钮丢了 open-type=share');
|
|
assert.match(src, /:data-card-id="card && card\.card_id"/,
|
|
'分享按钮未带 data-card-id(dataset 通道依赖它)');
|
|
assert.match(src, /@tap="rememberShareCard"/,
|
|
'分享按钮未在 tap 时记住卡片(storage 通道依赖它)');
|
|
});
|
|
|
|
test('C2: diy-card-mini 引入 rememberShareCardId 并实现 rememberShareCard', () => {
|
|
const src = read('components-diy/diy-card-mini.vue');
|
|
assert.match(src, /import\s*\{[^}]*rememberShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/);
|
|
assert.match(src, /rememberShareCard\(\)\s*\{[\s\S]*?rememberShareCardId\(this\.card && this\.card\.card_id\)/);
|
|
});
|
|
|
|
// =============================================================
|
|
// D. 「我的名片」组件把分享带来的 card_id 回传后端(diy-card-self.vue)
|
|
// =============================================================
|
|
|
|
test('D1: diy-card-self 从路由 query 取 card_id 并传给 mycard', () => {
|
|
const src = strip(read('components-diy/diy-card-self.vue'));
|
|
assert.match(src, /routeQueryValue\('card_id'\)/,
|
|
'未从路由 query 取分享带来的 card_id');
|
|
assert.match(src, /card_id:\s*shareCardId/,
|
|
'请求参数未带 card_id');
|
|
});
|
|
|
|
test('D2: 分享卡优先于登录引导空态(fallbackMode=none 也照常取分享卡)', () => {
|
|
const src = strip(read('components-diy/diy-card-self.vue'));
|
|
assert.match(src, /!this\.hasToken && this\.fallbackMode === 'none' && !shareCardId/,
|
|
'登录引导早退条件未排除"有分享卡"的情况');
|
|
});
|
|
|
|
// =============================================================
|
|
// E. 全局兜底 mixin 的 dataset 分支仍在(其它页面继续受益)
|
|
// =============================================================
|
|
|
|
test('E1: mp-share-default 的 dataset.cardId 分支未被破坏', () => {
|
|
const src = strip(read('common/js/mp-share-default.js'));
|
|
assert.match(src, /options\.target\.dataset\.cardId/,
|
|
'全局兜底 mixin 的 dataset 分支丢了');
|
|
assert.match(src, /path:\s*'\/pages_tool\/card\/detail\?card_id=' \+ d\.cardId/);
|
|
});
|