feat(card): 增加有效期展示功能,支持独立成块样式与简介样式配置

This commit is contained in:
2026-10-28 17:41:16 +08:00
parent 60cac8780b
commit d789dd7142
3 changed files with 153 additions and 3 deletions

View File

@@ -177,12 +177,13 @@
<text v-if="showQrTip" class="qr-tip" :style="labelStyle" @tap="previewQrImg"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
</view>
<!-- 名片标签(2.22) + 来源标注(2.22) -->
<view v-if="blk === 'meta' && (showTagsRow || showSourceRow)" class="mini-meta" :key="blk">
<!-- 名片标签(2.22) + 来源标注(2.22) + 有效期(2.24); 2.24 起可独立成块/逐向间距 -->
<view v-if="blk === 'meta' && (showTagsRow || showSourceRow || showExpireRow)" class="mini-meta" :style="metaFinalStyle" :key="blk">
<view v-if="showTagsRow" class="mini-tags">
<text v-for="(t, i) in cardTags" :key="'t' + i" class="mini-tag">{{ t }}</text>
</view>
<text v-if="showSourceRow" class="mini-source">{{ sourceName }}</text>
<text v-if="showExpireRow" class="mini-source">有效期至 {{ expireDateText }}</text>
</view>
</template>
@@ -1163,6 +1164,21 @@ export default {
showSourceRow() {
return flagOn(this.c.showSource, false) && !!this.sourceName;
},
// ---- 2.24 有效期(自动展示: 名片设了 expire_at 就显示, 无独立开关) ----
showExpireRow() {
return !!(this.card && num(this.card.expire_at, 0) > 0);
},
expireDateText() {
const ts = num(this.card && this.card.expire_at, 0);
if (ts <= 0) return '';
const d = new Date(ts * 1000);
const p = (n) => (n < 10 ? '0' + n : '' + n);
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
},
// ---- 2.24 标签与来源(meta)区块: 可独立成块 + 逐向间距(与 bio/social 同款) ----
metaFinalStyle() {
return blockFinalStyle(this.c, 'meta');
},
/**
* 二维码是否「独立成块」: 后台「二维码独立样式」开关(与资料区同套逻辑)。
* 跟随卡片 → false(与上方资料同底连为一体, 无白卡/描边/阴影);

View File

@@ -115,7 +115,34 @@ test('A6: pickShareCardId 无 dataset 时回落 storage(朋友圈通道)', () =>
assert.equal(pickShareCardId({ from: 'menu' }), 'C_FROM_STORAGE');
});
test('A7: pickShareCardId 两路都没有 → 空', () => {
test('A6b: 第三级兜底 — storage 空且落地 query 带 card_id → 续传(右上角转发 S5-2)', () => {
// 场景: 访客经分享落地(query 带 card_id), 未点过任何分享按钮(storage 空),
// 直接右上角「···」转发 → 应自动续传落地页的源头卡
const uni = makeUni();
const { pickShareCardId } = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { id: '3', card_id: 'C_LANDING' },
}]));
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
assert.equal(pickShareCardId(null), 'C_LANDING');
});
test('A6c: 三级优先级 dataset > storage > 落地 query', () => {
const uni = makeUni();
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { card_id: 'C_LANDING' },
}]));
// query 在, 但 dataset 最准 → dataset 胜
assert.equal(pickShareCardId({ target: { dataset: { cardId: 'C_DATASET' } } }), 'C_DATASET');
// storage 在(刚点过按钮), dataset 无 → storage 胜(按钮意图更近)
rememberShareCardId('C_FROM_BTN');
assert.equal(pickShareCardId(null), 'C_FROM_BTN');
// 都没有 → 落地 query
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
});
test('A7: pickShareCardId 三路都没有 → 空', () => {
const uni = makeUni();
const { pickShareCardId } = loadCardShare(uni, () => []);
assert.equal(pickShareCardId({ from: 'menu' }), '');
@@ -280,6 +307,80 @@ test('B9: 行为级 — 朋友圈无 card_id 时原样回落', () => {
assert.equal(hooks.onShareTimeline.call(ctx), ctx.mpShareData.timeLine);
});
// =============================================================
// B+. H5 公众号分享链接补 card_id(setPublicShare, S10-1)
// =============================================================
/** 把 diy.js 的 setPublicShare 抠出来真跑(同 loadDiyShareHooks 思路) */
function loadDiySetPublicShare(share) {
const src = read('common/js/diy.js');
const start = src.indexOf('setPublicShare() {');
const end = src.indexOf('onPageScroll(e) {');
assert.ok(start > 0 && end > start, '未能定位 diy.js 的 setPublicShare');
// 剥注释后, 丢掉切片尾部的外层 methods 闭括号(setPublicShare 自己的 '}' 是倒数第二个)
let body = strip(src.slice(start, end));
body = body.slice(0, body.lastIndexOf('}')).trim();
const factory = new Function('routeQueryValue', 'appendPathParam', 'return {' + body + '};');
// 工厂返回 { setPublicShare: fn } 对象, 这里直接交出方法本体
return factory(share.routeQueryValue, share.appendPathParam).setPublicShare;
}
test('B10: diy.js 引入 routeQueryValue 且 setPublicShare 补 card_id', () => {
const src = read('common/js/diy.js');
assert.match(src, /import\s*\{[^}]*routeQueryValue[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
'未引入 card-share.js 的 routeQueryValue');
const stripped = strip(src);
const start = stripped.indexOf('setPublicShare()');
const end = stripped.indexOf('onPageScroll');
assert.ok(start > 0 && end > start, '未定位到 setPublicShare');
const seg = stripped.slice(start, end);
assert.match(seg, /routeQueryValue\('card_id'\)/, 'setPublicShare 未从落地 query 取 card_id');
assert.match(seg, /appendPathParam\(shareUrl, 'card_id', shareCardId\)/, '分享链接未追加 card_id');
});
test('B11: 行为级 — H5 落地带 card_id 时公众号分享链接续传', () => {
const uni = makeUni();
const share = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { id: '3', card_id: 'C_1_shop_x' },
}]));
const setPublicShare = loadDiySetPublicShare(share);
let captured = null;
const ctx = {
$config: { h5Domain: 'https://h5.example.com' },
diyRoute: '/pages_tool/index/diy',
id: 3,
name: '',
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
$langConfig: { getField: () => '标题' },
diyData: { global: {} },
siteInfo: null,
};
setPublicShare.call(ctx);
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3&card_id=C_1_shop_x',
'H5 分享链接未续传落地 card_id');
});
test('B12: 行为级 — 裸 Tab(无 card_id)时 H5 分享链接行为零变化', () => {
const uni = makeUni();
const share = loadCardShare(uni, () => ([]));
const setPublicShare = loadDiySetPublicShare(share);
let captured = null;
const ctx = {
$config: { h5Domain: 'https://h5.example.com' },
diyRoute: '/pages_tool/index/diy',
id: 3,
name: '',
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
$langConfig: { getField: () => '标题' },
diyData: { global: {} },
siteInfo: null,
};
setPublicShare.call(ctx);
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3',
'无 card_id 时链接应保持原样(不得追加空参数)');
});
// =============================================================
// C. 名片基件的分享按钮(diy-card-mini.vue)
// =============================================================

View File

@@ -104,3 +104,36 @@ test('多语言: 请求带 lang 参数(后端按 _en 优先下发)', () => {
f + ' 请求未带 lang 参数');
}
});
// ---- 2.24: 简介文字样式可配 + meta 区块独立成块 + 有效期展示 ----
test('2.24 简介样式: 消费 bioSize/bioColor/bioLineHeight(后端补 3 键后可配)', () => {
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?num\(c\.bioSize, 13\)/,
'简介字号未读 bioSize(回退 13)');
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?c\.bioColor \|\| c\.infoColor/,
'简介颜色未读 bioColor(应回退 infoColor)');
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?num\(c\.bioLineHeight, 22\)/,
'简介行高未读 bioLineHeight(回退 22)');
});
test('2.24 meta 区块: 与 bio/social 同款, 可独立成块 + 逐向间距', () => {
assert.match(miniSrc, /metaFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'meta'\)/,
'meta 块未走通用独立成块样式(blockFinalStyle(this.c, "meta"))');
// 模板挂上 metaFinalStyle
assert.match(miniSrc, /class="mini-meta"[\s\S]*?:style="metaFinalStyle"/,
'meta 块模板未挂 metaFinalStyle');
// 区块流 token 含 meta(可参与排序)
assert.match(miniSrc, /BLOCK_TOKENS = \['bio', 'rows', 'acts', 'social', 'qr', 'meta'\]/,
'BLOCK_TOKENS 应含 meta token');
});
test('2.24 有效期: expire_at 自动展示「有效期至」(无独立开关)', () => {
assert.match(miniSrc, /showExpireRow\(\)\s*\{[\s\S]*?this\.card && num\(this\.card\.expire_at, 0\) > 0/,
'有效期行未按 card.expire_at>0 判定');
assert.match(miniSrc, /expireDateText\(\)\s*\{[\s\S]*?this\.card && this\.card\.expire_at/,
'缺少有效期日期格式化');
// 模板渲染「有效期至」且计入 meta 块可见性
assert.match(miniSrc, /有效期至 \{\{ expireDateText \}\}/, '模板未渲染「有效期至」');
assert.match(miniSrc, /showTagsRow \|\| showSourceRow \|\| showExpireRow/,
'meta 块可见性未纳入有效期行');
});