feat(card): 名片区块流与基础信息补齐(2.22/2.23) + 分享带名片ID

- 组件支持六个「可独立成块」模块任意排序(区块流): 资料行/快捷操作/社交/二维码/
  标签来源的位置可调, 头部恒最上、发名片恒最下; 二维码位置档位归并为 flow。
- 补齐「名片基础信息」遗漏字段: 简介区块、社交区块、名片附加信息(过期遮罩/
  地址细分/来源), 并与 wap 端展示口径对齐。
- 小程序分享给游客时携带名片 ID: 分享 path 追加 card_id, 访客打开「我的名片」
  即看到分享者那张卡。新增 common/js/card-share.js 统一处理「取卡ID / 拼参数」
  (dataset 通道 + storage 通道兜底, 朋友圈场景靠 storage)。
This commit is contained in:
2026-10-28 16:37:12 +08:00
parent 66aa66d2c1
commit 5e6eec166a
14 changed files with 1245 additions and 99 deletions

164
common/js/card-share.js Normal file
View File

@@ -0,0 +1,164 @@
/**
* 名片分享 · 跨端共享工具(小程序 / H5 / 鸿蒙通用)
*
* 背景(2026-10-28):
* 「我的名片」组件(components-diy/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 分支, 导致"分享给游客时名片 ID 丢失"。
*
* 本模块提供三条通道(任一命中即可):
* ① dataset 通道: 页面钩子 onShareAppMessage(options) 里读
* options.target.dataset.cardId(微信会把 data-card-id 转成 dataset.cardId)
* —— 最准, 但只覆盖"发送给朋友"。
* ② storage 通道: 按钮 @tap 时把 card_id 写进 storage, 钩子读取后立即清除
* —— onShareTimeline 不接受事件参数, 只能走这条(朋友圈)。
* ③ 路由 query 通道: 分享落地页从当前页 options 取 card_id,
* 供「我的名片」组件回传给 /card/api/diy/mycard(访客看到分享者那张卡)。
*
* 纯 JS(无 wxss), 小程序/H5/鸿蒙通用; 无 uni 运行时环境(如 node 单测)全部静默降级。
*/
/** storage 槽: 记住"最近一次点分享按钮的卡片" */
export const SHARE_CARD_STORAGE_KEY = '_cardShareCardId';
/** storage 记忆的最长有效期(毫秒) —— 超过则认为已过期, 不再用于分享 */
export const SHARE_CARD_MEMORY_TTL = 5 * 60 * 1000;
/**
* 记住"正在分享的卡片"(按钮 @tap 时调用)
* @param {string|number} cardId
* @returns {string} 实际记住的 card_id(空串表示未记住)
*/
export function rememberShareCardId(cardId) {
const v = String(cardId == null ? '' : cardId).trim();
if (!v) return '';
try {
uni.setStorageSync(SHARE_CARD_STORAGE_KEY, { card_id: v, at: Date.now() });
} catch (e) {
/* 无 uni 运行时 / 存储失败: 静默, 退回 dataset 通道 */
}
return v;
}
/**
* 取出并清除记住的 card_id(钩子里调用)
* 读取后立即清除 —— 避免下一次"右上角···转发"复用了上一次按钮点击的卡片。
* @param {number} [maxAgeMs] 有效期上限, 默认 5 分钟
* @returns {string}
*/
export function takeRememberedShareCardId(maxAgeMs = SHARE_CARD_MEMORY_TTL) {
let v = '';
try {
const raw = uni.getStorageSync(SHARE_CARD_STORAGE_KEY);
if (raw && typeof raw === 'object' && raw.card_id) {
const at = Number(raw.at || 0);
if (!maxAgeMs || Date.now() - at <= maxAgeMs) {
v = String(raw.card_id).trim();
}
} else if (typeof raw === 'string' && raw) {
// 兼容早期纯字符串格式
v = raw.trim();
}
} catch (e) {
v = '';
}
try {
uni.removeStorageSync(SHARE_CARD_STORAGE_KEY);
} catch (e) {
/* ignore */
}
return v;
}
/**
* 从分享事件里挑出名片 ID(dataset 优先, storage 兜底)
* @param {object} [options] onShareAppMessage 的事件对象
* @returns {string}
*/
export function pickShareCardId(options) {
try {
const ds = options && options.target && options.target.dataset;
if (ds && ds.cardId) {
const v = String(ds.cardId).trim();
if (v) return v;
}
} catch (e) {
/* ignore */
}
return takeRememberedShareCardId();
}
/**
* 取当前页面路由 query 的某个值(分享落地页带 card_id 时用)
* 兼容三种取法: 原生 options / uni 封装的 $page.options / 字符串 query
* @param {string} key
* @returns {string}
*/
export function routeQueryValue(key) {
try {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [];
if (!pages.length) return '';
const cur = pages[pages.length - 1] || {};
let opts = cur.options || (cur.$page && cur.$page.options) || null;
if (opts && typeof opts === 'object') {
const v = opts[key];
return v == null ? '' : String(v).trim();
}
if (typeof cur.query === 'string' && cur.query) {
const m = cur.query.match(new RegExp('(?:^|&)' + key + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : '';
}
if (cur.query && typeof cur.query === 'object') {
const v = cur.query[key];
return v == null ? '' : String(v).trim();
}
} catch (e) {
/* ignore */
}
return '';
}
/**
* 给页面 path 追加一个 query 参数
* @param {string} path 形如 /pages_tool/index/diy?id=1
* @param {string} key
* @param {string} value
* @returns {string}
*/
export function appendPathParam(path, key, value) {
const p = String(path == null ? '' : path).trim();
const v = String(value == null ? '' : value).trim();
if (!v) return p;
if (!p) return '?' + encodeURIComponent(key) + '=' + encodeURIComponent(v);
const sep = p.indexOf('?') >= 0 ? '&' : '?';
return p + sep + encodeURIComponent(key) + '=' + encodeURIComponent(v);
}
/**
* 给 query 串追加一个参数(朋友圈 onShareTimeline 只认 query)
* @param {string} query 形如 id=1&name=xx
* @param {string} key
* @param {string} value
* @returns {string}
*/
export function appendQueryParam(query, key, value) {
const q = String(query == null ? '' : query).trim().replace(/^[?&]+/, '');
const v = String(value == null ? '' : value).trim();
if (!v) return q;
if (!q) return encodeURIComponent(key) + '=' + encodeURIComponent(v);
return q + '&' + encodeURIComponent(key) + '=' + encodeURIComponent(v);
}
/**
* 取 path 里的 query 段(不带 ? 前缀)
* @param {string} path
* @returns {string}
*/
export function pathQueryPart(path) {
const p = String(path == null ? '' : path);
const i = p.indexOf('?');
return i >= 0 ? p.slice(i + 1) : '';
}

View File

@@ -3,6 +3,7 @@ import Config from '@/common/js/config.js';
import util from '@/common/js/util.js';
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
import { pickShareCardId, appendPathParam, appendQueryParam, pathQueryPart } from '@/common/js/card-share.js';
let systemInfo = util.getDeviceInfo();
@@ -823,11 +824,50 @@ export default {
}, 50);
},
// 分享给好友
onShareAppMessage() {
//
// 2026-10-28 名片分享带卡 ID:
// 名片组件(「我的名片」diy-card-self / 精选名片)的「发送名片」按钮在小程序端是
// <button open-type="share">, 微信只认**页面级**钩子。本钩子来自本 mixin,
// Vue2 defaultStrat(子覆盖父)会顶掉全局兜底 mixin(mp-share-default.js)里的
// dataset.cardId 分支 —— 所以在这里把名片 ID 重新接回来:
// 拿到 card_id → 分享 path 追加 card_id → 访客落地即可看到分享者那张卡。
// 拿不到(右上角「···」转发等无按钮上下文的场景)→ 回落原有 mpShareData。
onShareAppMessage(options) {
const cardId = pickShareCardId(options);
if (cardId) {
const base = this.mpShareData?.appMessage || {};
// 分享 path 优先用**当前页** path(带装修模板 id 等 query) ——
// 后台配置的 base.path 未必是用户当前所在的那一页;
// 拿不到当前页时(极少见)才回落 base.path。
const target = this.$util.getCurrentRoute().path || base.path || '';
return {
title: base.title || '小程序分享',
path: appendPathParam(target, 'card_id', cardId),
imageUrl: base.imageUrl || ''
};
}
return this.mpShareData?.appMessage;
},
// 分享到朋友圈
//
// 朋友圈只支持 query(且 onShareTimeline **不接受事件参数**) → 拿不到 dataset,
// 只能走 storage 通道(按钮 @tap 时记住 card_id)。
// 注意: util.getMpShare() 塞进 timeLine.query 的其实是**完整 path**(不是 query 段),
// 这里统一用 pathQueryPart 取真正的 query 段再拼 card_id。
onShareTimeline() {
return this.mpShareData?.timeLine;
const base = this.mpShareData?.timeLine;
const cardId = pickShareCardId(null);
if (!cardId) {
return base;
}
let q = pathQueryPart((base && base.query) || '');
if (!q) {
q = pathQueryPart(this.$util.getCurrentRoute().path);
}
return {
title: (base && base.title) || '小程序分享',
query: appendQueryParam(q, 'card_id', cardId),
imageUrl: (base && base.imageUrl) || ''
};
}
}

View File

@@ -196,7 +196,8 @@ export function rowActOf(row) {
}
/** 二维码位置(四档): rows 资料行下方(默认) / head 头部右侧 / corner 右上角浮层 / bottom 主按钮上方 */
export const QR_POSITIONS = ['rows', 'head', 'corner', 'bottom'];
/** 2.23 新增 flow(卡片流内): rows/bottom 两档归并为流内位置, 具体位置由区块顺序决定 */
export const QR_POSITIONS = ['flow', 'rows', 'head', 'corner', 'bottom'];
/** 位置档位中文别名(面板下拉若直接存中文档名, 归一化时兼容) */
const QR_POSITION_ALIAS = {
'资料行下方': 'rows',
@@ -213,7 +214,7 @@ export function normalizeQrPosition(v) {
/** 二维码默认尺寸(rpx); 面板「二维码尺寸」留空/0 = 按位自动取此档。
* rows/bottom(流内大码)240, head(头部右侧)200 —— 以 2026-10-22 用户参考图(C 端运行效果)定稿;
* corner(右上浮层, 占 Logo 位)空间受限保持 120 */
export const QR_SIZES = { rows: 240, head: 200, corner: 120, bottom: 240 };
export const QR_SIZES = { flow: 240, rows: 240, head: 200, corner: 120, bottom: 240 };
/**
* 二维码外观样式(四档): plain 原图 / card 白卡描边(默认) / theme 主题色底 /

View File

@@ -243,7 +243,7 @@ export default {
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/cards',
data: { card_ids: this.cardIds },
data: { card_ids: this.cardIds, lang: uni.getStorageSync('lang') || 'zh-cn' },
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {

View File

@@ -101,63 +101,19 @@
</view>
</view>
<!-- 快捷操作·资料区上方档(2.18 actsPosition=above): below 时由下方同构块渲染 -->
<view v-if="actsAbove && showActs && acts.length && actsGrid" class="mini-acts-grid" :style="actsBlockFinalStyle">
<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>
<!-- 2.23 区块流: 按 c.blockOrder 渲染, 六个可独立成块的模块任意排序 -->
<template v-for="blk in blockSeq">
<!-- 简介区块(2.22: 补齐「名片基础信息」遗漏字段; 可独立成块, 与资料区同款) -->
<view v-if="blk === 'bio' && showBioBlock" class="mini-bio" :style="bioFinalStyle" :key="blk">
<view v-if="bioTitleShow" class="mini-bio-title">
<view class="bio-title-bar"></view>
<text class="bio-title-text">{{ bioTitle }}</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="actsBlockFinalStyle">
<view v-for="(act, i) in acts" :key="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="rowsFinalStyle">
<!-- 资料区标题(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), 'row-3col': showRowLabels }" :hover-class="rowTapable(row) ? 'btn-press' : 'none'" @tap="onRowTap(row)">
<!-- 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-if="showRowLabels || (!rowCssIcon(row) && !rowIcon(row))" class="row-label" :style="rowLabelStyle">{{ row.label }}</text>
<text class="row-value" :style="valueStyle">{{ row.value }}</text>
<text v-if="copyBtnVisible(row)" 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>
</view>
</view>
<!-- 二维码·资料行下方(rows 档, 默认; 点击预览名片); mini-qr-center: 块级居中 -->
<view v-if="showQrRows" class="mini-qr mini-qr-center" :style="qrBlockFinalStyle">
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<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>
<text class="mini-bio-text" :style="bioTextStyle" :class="{ 'bio-text-clamp': bioMaxLines > 0 }">{{ bioText }}</text>
</view>
<!-- 快捷操作·图标宫格档(grid): 彩色浅底图标 + 下方文字 -->
<view v-if="!actsAbove && showActs && acts.length && actsGrid" class="mini-acts-grid" :style="actsBlockFinalStyle">
<view v-if="blk === 'acts' && showActs && acts.length && actsGrid" class="mini-acts-grid" :style="actsBlockFinalStyle" :key="blk">
<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 同源) / 首字兜底 -->
@@ -171,23 +127,75 @@
</view>
<!-- 快捷操作·胶囊芯片档(chips, 历史默认; 最小触达 72rpx; 首个为主动作) -->
<view v-if="!actsAbove && showActs && acts.length && !actsGrid" class="mini-acts" :style="actsBlockFinalStyle">
<view v-if="blk === 'acts' && showActs && acts.length && !actsGrid" class="mini-acts" :style="actsBlockFinalStyle" :key="blk">
<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>
: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>
<!-- 二维码·主按钮上方(bottom 档; 点击预览名片) -->
<view v-if="showQrBottom" class="mini-qr mini-qr-center" :style="qrBlockFinalStyle">
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<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 v-if="blk === 'rows' && showRowsBlock && shownRows.length" class="mini-rows" :style="rowsFinalStyle" :key="blk">
<!-- 资料区标题(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), 'row-3col': showRowLabels }" :hover-class="rowTapable(row) ? 'btn-press' : 'none'" @tap="onRowTap(row)">
<!-- 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-if="showRowLabels || (!rowCssIcon(row) && !rowIcon(row))" class="row-label" :style="rowLabelStyle">{{ row.label }}</text>
<text class="row-value" :style="valueStyle">{{ row.value }}</text>
<text v-if="copyBtnVisible(row)" 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>
</view>
</view>
<!-- 发名片(对齐名片详情页: 主题色大按钮; 点击跳原生转发中转页拉起微信转发面板) -->
<view v-if="showSendBtn" class="mini-send" :style="sendBtnStyle" hover-class="btn-press" @tap.stop="onSend">
<text class="send-text">{{ sendBtnText || '发名片' }}</text>
<!-- 社交媒体区块(2.22: 抖音/小红书/微博/领英等; 可独立成块) -->
<view v-if="blk === 'social' && showSocialBlock" class="mini-social" :style="socialFinalStyle" :key="blk">
<view v-for="(s, i) in socialList" :key="i" class="social-item" hover-class="btn-press" @tap.stop="onSocialTap(s)">
<text class="social-label">{{ s.label }}</text>
<text v-if="s.value" class="social-value">{{ s.value }}</text>
</view>
</view>
<!-- 二维码·卡片流内(位置由区块顺序决定; 点击预览名片) -->
<view v-if="blk === 'qr' && showQrFlow" class="mini-qr mini-qr-center" :style="qrBlockFinalStyle" :key="blk">
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<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">
<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>
</view>
</template>
<!-- 发名片(对齐名片详情页: 主题色大按钮; 点击跳原生转发中转页拉起微信转发面板) -->
<view v-if="showSendBtn" class="mini-send" :style="sendBtnStyle" hover-class="btn-press" @tap.stop="onSend">
<text class="send-text">{{ sendBtnText || '发名片' }}</text>
</view>
<!-- 过期遮罩(2.22: 名片有效期到期; 与名片详情页同口径) -->
<view v-if="cardExpired" class="mini-expired">
<text class="expired-text">该名片已过期</text>
</view>
</view>
</view>
@@ -196,6 +204,7 @@
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
<button v-if="showDetailBtn" class="mini-detail" :style="detailBtnStyle" hover-class="btn-press"
open-type="share"
@tap="rememberShareCard"
:data-card-id="card && card.card_id" :data-card-name="card && card.name" :data-card-qr="card && card.qr_url">
<text class="detail-text">{{ detailBtnText || '发送名片' }}</text>
</button>
@@ -248,6 +257,22 @@ import {
// 2.19: 显隐/文案/自定义动作归一化 + 素材图标库(与后端 CardIconLibrary 同源)
normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg,
} from './diy-card-common.js';
// 2026-10-28: 分享带名片 ID —— 按钮 @tap 时记住当前卡片, 供页面级分享钩子取用
// (微信只认页面级 onShareAppMessage; onShareTimeline 不接受事件参数, 只能走 storage 通道)
import { rememberShareCardId } from '@/common/js/card-share.js';
/**
* 2.23 区块流: 可独立成块的模块集合与默认顺序
*
* token: bio(简介) / rows(资料区) / acts(快捷操作) / social(社交媒体)
* qr(二维码·流内) / meta(标签与来源标注)
* 头部恒在最上、发名片按钮恒在最下, 不参与排序;
* 二维码的 head/corner 两档是「锚点」语义(头部内 / 右上角浮层), 也不进区块流。
* 默认顺序 = 简介 → 快捷操作(资料区上方, 兼容历史 actsPosition=above)
* → 资料区 → 社交媒体 → 二维码 → 标签来源
*/
const BLOCK_TOKENS = ['bio', 'rows', 'acts', 'social', 'qr', 'meta'];
const DEFAULT_BLOCK_ORDER = 'bio,acts,rows,social,qr,meta';
/**
* 2.20 三块「独立成块」逐向间距: 开关开启时输出逐向 margin/padding 覆盖串。
@@ -262,6 +287,47 @@ function blockCustomSpacing(c, prefix) {
return 'margin: ' + m.join('rpx ') + 'rpx; padding: ' + p.join('rpx ') + 'rpx;';
}
/**
* 2.22 通用「独立成块」判定(简介/社交与资料区/二维码同款口径)
*
* 判定优先级: ① 显式模式串(follow/card/跟随卡片 → 跟随; block/indep/独立成块 → 独立)
* ② 隐式开关(配了底色/圆角/内边距即独立成块, 与面板 chip 同款)
* prefix 语义: <prefix>BlockBg / <prefix>BlockRadius / <prefix>BlockPad
*/
function blockIndependent(c, prefix) {
const mode = String(
c[prefix + 'BlockStyle'] || c[prefix + 'BlockMode'] || c[prefix + 'Mode'] ||
c[prefix + 'Style'] || c[prefix + 'CardMode'] || c[prefix + 'IndepStyle'] ||
c[prefix + 'BlockType'] || c[prefix + 'Type'] || ''
).trim().toLowerCase();
if (/^(follow|card|跟随卡片)$/.test(mode)) return false;
if (/^(block|indep|independent|standalone|独立成块)$/.test(mode)) return true;
return !!(String(c[prefix + 'BlockBg'] || '').trim() || num(c[prefix + 'BlockRadius'], 0) || num(c[prefix + 'BlockPad'], 0));
}
/**
* 2.22 通用「独立成块」最终样式串:
* 独立成块 → 底色/圆角/内边距 + 逐向间距(纯数值, 不用 calc())
* 跟随卡片 → 去掉白卡底(与上方同底连为一体)
* 注: 纯字符串拼接(不依赖组件实例), WXSS 不支持 calc()。
*/
function blockFinalStyle(c, prefix) {
const custom = blockCustomSpacing(c, prefix);
if (blockIndependent(c, prefix)) {
const bg = String(c[prefix + 'BlockBg'] || '').trim();
const radius = num(c[prefix + 'BlockRadius'], 0);
const pad = num(c[prefix + 'BlockPad'], 0);
let text = '';
if (bg) text += 'background: ' + bg + ';';
if (radius > 0) text += 'border-radius: ' + px2rpx(radius) + 'rpx;';
if (pad > 0) text += 'padding: ' + px2rpx(pad) + 'rpx;';
if (custom) text = (text + ' ' + custom).trim();
return text;
}
return ('background: transparent; border: 0; box-shadow: none; border-radius: 0; padding: 0; '
+ (custom || '')).trim();
}
export default {
name: 'diy-card-mini',
props: {
@@ -367,6 +433,49 @@ export default {
showQrBottom() { return this.showQrBase && this.qrPos === 'bottom'; },
showQrHead() { return this.showQrBase && this.qrPos === 'head'; },
showQrCorner() { return this.showQrBase && this.qrPos === 'corner'; },
/**
* 2.23 二维码在卡片流内(位置由区块顺序决定)。
* rows/bottom 两个历史档位都归入流内; head/corner 是锚点档, 不进流内。
*/
showQrFlow() {
return this.showQrBase && (this.qrPos === 'flow' || this.qrPos === 'rows' || this.qrPos === 'bottom');
},
/**
* 2.23 区块流顺序: 配置 c.blockOrder 优先(逗号分隔 token);
* 缺省回落默认顺序(并按历史 actsPosition 推导快捷操作在资料区上/下);
* 未知 token 忽略, 缺失的已知 token 按默认顺序补齐。
*/
blockSeq() {
const raw = String(this.c.blockOrder || '').trim();
const list = [];
if (raw) {
raw.split(',').forEach((t) => {
const token = t.trim();
if (token && BLOCK_TOKENS.indexOf(token) >= 0 && list.indexOf(token) < 0) list.push(token);
});
}
if (!list.length) {
const actsAbove = normalizeActsPosition(this.c.actsPosition) === 'above';
list.push('bio');
if (actsAbove) list.push('acts');
list.push('rows');
if (!actsAbove) list.push('acts');
list.push('social', 'qr', 'meta');
return list;
}
// 补齐缺失的已知 token: 插到默认顺序里它前一个已存在 token 之后
const def = DEFAULT_BLOCK_ORDER.split(',');
def.forEach((t) => {
if (list.indexOf(t) >= 0) return;
let at = -1;
for (let i = def.indexOf(t) - 1; i >= 0; i--) {
const p = list.indexOf(def[i]);
if (p >= 0) { at = p + 1; break; }
}
if (at < 0) list.unshift(t); else list.splice(at, 0, t);
});
return list;
},
/** 头像开关(历史配置无此字段时默认显示) */
showAvatarBlock() {
return flagOn(this.c.showAvatar, true);
@@ -392,6 +501,8 @@ export default {
*/
cornerClearStyle() {
if (this.qrPos !== 'corner' || !this.showQrBase) return {};
// 2.23 区块流: 右上角浮层压住的是「紧邻头部的那块」, 资料区被排到别处时不再让位
if (this.blockSeq[0] !== 'rows') return {};
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const pad = this.qrStyle === 'plain' ? 0 : (this.qrStyle === 'gradient' ? 6 : 10) * 2;
const tip = this.showQrTip ? 42 : 0;
@@ -588,7 +699,7 @@ export default {
},
rowsInfo() {
if (!this.card) return { shown: [], hasMore: false };
return visibleRows(this.card.rows, this.expanded ? 0 : this.maxRows);
return visibleRows(this.decorateRows(this.card.rows), this.expanded ? 0 : this.maxRows);
},
shownRows() {
return this.rowsInfo.shown;
@@ -856,8 +967,15 @@ export default {
return this.acts.length > 4 ? '20%' : '25%';
},
/** 快捷操作区位置(2.18): above=资料区上方(below 历史默认零变化) */
/**
* 2.23 快捷操作是否排在资料区之上 —— 由区块流顺序决定
* (旧 actsPosition 开关只作兜底: 区块流缺块时才回落到它)
*/
actsAbove() {
return normalizeActsPosition(this.c.actsPosition) === 'above';
const i = this.blockSeq.indexOf('acts');
const r = this.blockSeq.indexOf('rows');
if (i < 0 || r < 0) return normalizeActsPosition(this.c.actsPosition) === 'above';
return i < r;
},
/**
* 快捷操作区独立块样式(2.18): 背景/圆角/内边距, 全为默认(历史形态)时输出空串零变化
@@ -985,6 +1103,66 @@ export default {
// 跟随卡片: 去掉 .mini-rows 静态白卡(白底/描边/阴影/圆角/内边距), 与上方资料同底连为一体
return ('background: transparent; border: 0; box-shadow: none; border-radius: 0; padding: 0; ' + extra).trim();
},
// ---- 2.22 简介区块(可独立成块, 与资料区同款) ----
bioText() {
return String((this.card && this.card.bio) || '').trim();
},
showBioBlock() {
return flagOn(this.c.showBio, false) && !!this.bioText;
},
bioTitleShow() {
return flagOn(this.c.bioTitleShow, false);
},
bioTitle() {
return String(this.c.bioTitle || '简介');
},
/** 简介最多行数(0 = 不限); 超出部分由 CSS line-clamp 截断 */
bioMaxLines() {
const n = num(this.c.bioMaxLines, 3);
return n > 0 ? n : 0;
},
bioFinalStyle() {
return blockFinalStyle(this.c, 'bio');
},
bioTextStyle() {
const c = this.c;
const style = {
fontSize: px2rpx(num(c.bioSize, 13)) + 'rpx',
color: String(c.bioColor || c.infoColor || '#888888'),
lineHeight: String(num(c.bioLineHeight, 22) / 10),
};
// 多行截断行数(WXSS 支持 -webkit-line-clamp, 需与 display:-webkit-box 同用)
const lines = this.bioMaxLines;
if (lines > 0) style['-webkit-line-clamp'] = lines;
return this.$util.objToStyle(style);
},
// ---- 2.22 社交媒体区块(可独立成块) ----
socialList() {
return (this.card && Array.isArray(this.card.socials)) ? this.card.socials : [];
},
showSocialBlock() {
return flagOn(this.c.showSocial, false) && this.socialList.length > 0;
},
socialFinalStyle() {
return blockFinalStyle(this.c, 'social');
},
// ---- 2.22 名片标签 ----
cardTags() {
return (this.card && Array.isArray(this.card.tags)) ? this.card.tags : [];
},
showTagsRow() {
return flagOn(this.c.showTags, false) && this.cardTags.length > 0;
},
// ---- 2.22 有效期 / 来源标注 ----
cardExpired() {
return !!(this.card && this.card.expired);
},
sourceName() {
return String((this.card && this.card.source_name) || '').trim();
},
showSourceRow() {
return flagOn(this.c.showSource, false) && !!this.sourceName;
},
/**
* 二维码是否「独立成块」: 后台「二维码独立样式」开关(与资料区同套逻辑)。
* 跟随卡片 → false(与上方资料同底连为一体, 无白卡/描边/阴影);
@@ -1060,6 +1238,48 @@ export default {
rowTapable(row) {
return flagOn(this.c.rowTap, true) && !!rowActOf(row);
},
/**
* 2.22 地址细分: address 行用完整地址(国家/省/市 + 街道)覆盖, 邮编独立成行。
* 与名片详情页同口径([country, province, city, address] + 邮编一行)。
*/
decorateRows(rows) {
const list = Array.isArray(rows) ? rows.map(r => Object.assign({}, r)) : [];
const full = String((this.card && this.card.address_full) || '').trim();
const postal = String((this.card && this.card.postal_code) || '').trim();
let addrIdx = -1;
for (let i = 0; i < list.length; i++) {
if (list[i].field === 'address') {
addrIdx = i;
if (full) list[i].value = full;
break;
}
}
if (postal) {
const row = { field: 'postal_code', label: '邮编', value: postal, href: '' };
if (addrIdx >= 0) list.splice(addrIdx + 1, 0, row);
else list.push(row);
}
return list;
},
/** 2.22 社交媒体点击: 有链接直接打开, 无链接则复制账号 */
onSocialTap(s) {
const href = String((s && s.href) || '').trim();
if (href) {
// #ifdef H5
window.open(href, '_blank');
// #endif
// #ifndef H5
uni.setClipboardData({ data: href, success: () => uni.showToast({ title: '链接已复制', icon: 'none' }) });
// #endif
return;
}
const value = String((s && s.value) || '').trim();
if (!value) return;
uni.setClipboardData({
data: value,
success: () => uni.showToast({ title: '账号已复制', icon: 'none' }),
});
},
onRowTap(row) {
const act = rowActOf(row);
if (act) this.$emit('row-act', act);
@@ -1068,6 +1288,14 @@ export default {
this.$emit('act', act);
},
/* ---------- 分享名片抽屉(「发送名片」弹出; 版式/逻辑与精选名片 show 完全同款) ---------- */
/**
* 2026-10-28: 记住"正在分享的卡片" —— 微信小程序只认页面级 onShareAppMessage,
* 组件内的 dataset 需由页面钩子读取; 朋友圈(onShareTimeline)不接受事件参数,
* 只能靠这个 storage 通道把 card_id 带到页面钩子。
*/
rememberShareCard() {
rememberShareCardId(this.card && this.card.card_id);
},
openCardShare() {
this.showShare = true;
},
@@ -1630,6 +1858,140 @@ export default {
}
}
/* ---- 2.22 简介区块(可独立成块, 视觉与资料区同款白卡) ---- */
.mini-bio {
position: relative;
z-index: 2;
margin-top: 24rpx;
background: #ffffff;
border-radius: 20rpx;
border: 1rpx solid #EEF0F3;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.08);
padding: 24rpx;
.mini-bio-title {
display: flex;
align-items: center;
margin-bottom: 12rpx;
.bio-title-bar {
width: 6rpx;
height: 24rpx;
border-radius: 3rpx;
background: #2F6BFF;
margin-right: 12rpx;
}
.bio-title-text {
font-size: 26rpx;
color: #1F2329;
font-weight: 500;
}
}
.mini-bio-text {
display: block;
word-break: break-word;
white-space: pre-wrap;
}
/* 多行截断(最多 bioMaxLines 行; 0 = 不限) */
.bio-text-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
/* ---- 2.22 社交媒体区块 ---- */
.mini-social {
position: relative;
z-index: 2;
margin-top: 24rpx;
background: #ffffff;
border-radius: 20rpx;
border: 1rpx solid #EEF0F3;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.08);
padding: 24rpx;
display: flex;
flex-wrap: wrap;
.social-item {
display: flex;
flex-direction: column;
width: 50%;
padding: 8rpx 0;
}
.social-label {
font-size: 22rpx;
color: #888888;
}
.social-value {
font-size: 26rpx;
color: #1F2329;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* ---- 2.22 标签 + 来源标注 ---- */
.mini-meta {
position: relative;
z-index: 2;
margin-top: 24rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
.mini-tags {
display: flex;
flex-wrap: wrap;
flex: 1;
}
.mini-tag {
font-size: 22rpx;
color: #6B7280;
background: #F2F4F7;
border-radius: 999rpx;
padding: 4rpx 16rpx;
margin: 0 12rpx 8rpx 0;
}
.mini-source {
font-size: 22rpx;
color: #B0B4BC;
margin-bottom: 8rpx;
}
}
/* ---- 2.22 过期遮罩(覆盖整卡) ---- */
.mini-expired {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 9;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.72);
border-radius: 20rpx;
.expired-text {
font-size: 28rpx;
color: #888888;
background: rgba(0, 0, 0, 0.06);
border-radius: 999rpx;
padding: 12rpx 32rpx;
}
}
.mini-rows {
position: relative;
z-index: 2;

View File

@@ -39,6 +39,8 @@
// 运行时判定: 已登录 → 本人名片(optionalAuth); 未登录 → 配置的兜底名片
// fallbackMode: enterprise=店铺企业名片 / card=指定 fallbackCardId / none=登录引导
// 视觉基件 diy-card-mini; 本文件管登录态/兜底/空态 CTA
import { routeQueryValue } from '@/common/js/card-share.js';
export default {
name: 'diy-card-self',
props: {
@@ -239,7 +241,12 @@ export default {
return isNaN(n) ? 0 : Math.round(n * 2);
},
fetchData() {
if (!this.hasToken && this.fallbackMode === 'none') {
// 2026-10-28: 分享落地时小程序 path 上带 card_id(见 common/js/diy.js 的分享钩子)
// → 访客(未登录)打开也能看到**分享者那张卡**, 而不是企业兜底卡。
// 注意: 分享卡优先于登录引导空态 —— fallbackMode=none 只表示"不兜底",
// 分享卡是显式指定的卡片, 不是兜底。
const shareCardId = routeQueryValue('card_id');
if (!this.hasToken && this.fallbackMode === 'none' && !shareCardId) {
this.card = null;
return;
}
@@ -249,6 +256,8 @@ export default {
data: {
fallback_mode: this.fallbackMode,
fallback_card_id: String((this.value || {}).fallbackCardId || '').trim(),
card_id: shareCardId,
lang: uni.getStorageSync('lang') || 'zh-cn',
},
success: res => {
this.loading = false;

View File

@@ -179,7 +179,7 @@ export default {
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/card',
data: { card_id: cardId },
data: { card_id: cardId, lang: uni.getStorageSync('lang') || 'zh-cn' },
success: res => {
this.loading = false;
this.card = (res.code == 0 && res.data && res.data.card) ? res.data.card : null;

View File

@@ -106,7 +106,8 @@ test('换 card_id: 成功后继续加载详情', () => {
// 4. 缺少名片编号的兜底
// ================================================================
test('既无 card_id 也无 scene: 提示「缺少名片编号」', () => {
assert.match(body, /this\.errorMsg = '缺少名片编号';/);
// 2026-10-27 调试增强: 文案带上实际收到的入参(loadOptions), 便于定位是哪种入口缺参
assert.match(body, /this\.errorMsg = `缺少名片编号 \$\{this\.loadOptions\}`;/);
});
// ================================================================

View File

@@ -0,0 +1,329 @@
/**
* 名片分享带卡 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/);
});

View File

@@ -83,16 +83,21 @@ test('common: rowActOf 由 href 派生点击动作', async () => {
assert.equal(m.rowActOf(null), null);
});
test('common: 二维码四档归一化 + 尺寸表', async () => {
test('common: 二维码档位归一化 + 尺寸表(2.23: flow/head/corner 三档 + rows/bottom 存量别名)', async () => {
const m = await import('../components-diy/diy-card-common.js');
for (const p of ['rows', 'head', 'corner', 'bottom']) {
// 2.23 对外三档: flow(卡片流内) / head(头部右侧) / corner(右上角浮层)
for (const p of ['flow', 'head', 'corner']) {
assert.equal(m.normalizeQrPosition(p), p);
}
// 存量别名: rows/bottom 仍归一化到自身(存量实例不炸), 渲染上等价 flow
for (const p of ['rows', 'bottom']) {
assert.equal(m.normalizeQrPosition(p), p);
}
// 未配置/非法值 → rows(保守默认, 不炸)
assert.equal(m.normalizeQrPosition(''), 'rows');
assert.equal(m.normalizeQrPosition('sideways'), 'rows');
assert.equal(m.normalizeQrPosition(undefined), 'rows');
assert.deepEqual(Object.keys(m.QR_SIZES).sort(), ['bottom', 'corner', 'head', 'rows']);
assert.deepEqual(Object.keys(m.QR_SIZES).sort(), ['bottom', 'corner', 'flow', 'head', 'rows']);
});
// =============================================================
@@ -116,17 +121,17 @@ test('mini: 资料行可点(开关 + href 派生 + emit row-act)', () => {
assert.match(miniSrc, /onRowTap\(row\)\s*\{[\s\S]*?this\.\$emit\('row-act',\s*act\)/);
});
test('mini: 二维码四档渲染分支齐全', () => {
test('mini: 二维码渲染分支齐全(2.23: flow 流内 + head/corner 锚点 + rows/bottom 存量别名)', () => {
assert.match(miniSrc, /showQrRows\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'rows'/);
assert.match(miniSrc, /showQrBottom\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'bottom'/);
assert.match(miniSrc, /showQrHead\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'head'/);
assert.match(miniSrc, /showQrCorner\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'corner'/);
// 模板四个挂点
// 2.23 流内档: flow + 存量 rows/bottom 归并到同一个流内挂点(位置由区块顺序决定)
assert.match(miniSrc, /showQrFlow\(\)\s*\{[\s\S]*?this\.qrPos === 'flow' \|\| this\.qrPos === 'rows' \|\| this\.qrPos === 'bottom'/);
// 模板挂点: 流内(区块流) / 头部右侧 / 右上角浮层
assert.match(miniSrc, /v-if="blk === 'qr' && showQrFlow" class="mini-qr mini-qr-center"/);
assert.match(miniSrc, /v-if="showQrCorner" class="mini-qr mini-qr-corner"/);
assert.match(miniSrc, /v-if="showQrHead" class="mini-qr mini-qr-head"/);
// rows/bottom 档流内块级, 带 mini-qr-center 水平居中(2026-10-22: 修复二维码偏左)
assert.match(miniSrc, /v-if="showQrRows" class="mini-qr mini-qr-center"/);
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*\}/);
// 总条件: 开关 + 有码源(qrSrc: 自定义图优先, 回落名片码)

View File

@@ -0,0 +1,106 @@
/**
* 名片 DIY 组件 ·「简介 / 社交媒体 / 标签 / 来源」契约测试(2.22, 2026-10-28)
*
* 背景: 名片 DIY 组件相对「名片基础信息」有字段遗漏(简介 bio 后端已下发但组件
* 不渲染、社交媒体 socials[] 整族缺失、标签/来源标注也没有)。
* 本次补齐, 且「简介」像资料区一样可独立成块 + 逐向微调。
*
* 覆盖:
* 1. 简介区块: 开关 + 独立成块判定 + 逐向间距(prefix=bio, 复用 blockCustomSpacing)
* 2. 社交/标签/来源: 字段读取与渲染
* 3. 地址细分: address 行用完整地址覆盖 + 邮编独立成行
* 4. 通用块样式工厂: blockIndependent / blockFinalStyle 的语义
* 5. 反例: 不得出现「简介写死 mock」「社交恒为空」这类假绿
*
* 跑法: node --test tests/diy-card-bio-social.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'));
test('简介区块: 开关 + 独立成块 + 逐向间距(bio 前缀)', () => {
// 开关
assert.match(miniSrc, /showBioBlock\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showBio, false\)/,
'缺少「显示简介」开关(flagOn(this.c.showBio))');
// 数据源: 名片简介
assert.match(miniSrc, /bioText\(\)\s*\{[\s\S]*?this\.card && this\.card\.bio/,
'简介文本未取 card.bio');
// 独立成块判定 + 逐向间距
assert.match(miniSrc, /bioFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'bio'\)/,
'简介未走通用独立成块样式(blockFinalStyle(this.c, "bio"))');
assert.match(miniSrc, /function blockFinalStyle\(c, prefix\)/,
'缺少通用 blockFinalStyle 工厂');
assert.match(miniSrc, /function blockCustomSpacing\(c, prefix\)/,
'缺少通用逐向间距工厂(应复用而非新写一套)');
// 逐向字段走**通用 prefix 拼接**(C 端不写字面量, 与资料区/二维码同一套)
assert.match(miniSrc, /side\('MarginTop'\)|c\[prefix \+ 'MarginTop'\]/,
'通用逐向工厂未按 prefix 拼字段名(bio 应复用同一套)');
// 标题与行数
assert.match(miniSrc, /bioTitleShow\(\)/, '缺少简介标题开关');
assert.match(miniSrc, /bioMaxLines\(\)/, '缺少简介行数控制');
});
test('简介渲染: 模板消费 bioText, 不是写死文案', () => {
assert.match(miniSrc, /class="mini-bio"[\s\S]*?\{\{ bioText \}\}/,
'简介块未渲染 bioText');
// 反例: 不得出现写死的简介正文
assert.doesNotMatch(miniSrc, /这是一段示例简介|示例简介文案/,
'简介正文疑似写死');
});
test('社交媒体: 读取 card.socials 并渲染, 可独立成块', () => {
assert.match(miniSrc, /socialList\(\)\s*\{[\s\S]*?this\.card && Array\.isArray\(this\.card\.socials\)/,
'社交列表未取 card.socials');
assert.match(miniSrc, /showSocialBlock\(\)/, '缺少社交显示开关');
assert.match(miniSrc, /socialFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'social'\)/,
'社交块未走通用独立成块样式');
assert.match(miniSrc, /class="mini-social"[\s\S]*?v-for="\(s, i\) in socialList"/,
'社交块未渲染列表');
// 反例: 不得把社交写死成空
assert.doesNotMatch(miniSrc, /socialList\(\)\s*\{\s*return \[\];/, '社交列表被写死为空');
});
test('名片标签 / 来源标注 / 过期提示', () => {
assert.match(miniSrc, /cardTags\(\)\s*\{[\s\S]*?this\.card && Array\.isArray\(this\.card\.tags\)/,
'标签未取 card.tags');
assert.match(miniSrc, /showTagsRow\(\)/, '缺少标签显示开关');
assert.match(miniSrc, /sourceName\(\)\s*\{[\s\S]*?this\.card && this\.card\.source_name/,
'来源标注未取 card.source_name');
assert.match(miniSrc, /showSourceRow\(\)/, '缺少来源显示开关');
assert.match(miniSrc, /cardExpired\(\)\s*\{[\s\S]*?this\.card && this\.card\.expired/,
'过期标记未取 card.expired');
assert.match(miniSrc, /class="mini-expired"/, '缺少过期遮罩元素');
});
test('地址细分: address 行用完整地址覆盖 + 邮编独立成行', () => {
assert.match(miniSrc, /decorateRows\(rows\)/, '缺少资料行装饰');
assert.match(miniSrc, /this\.card && this\.card\.address_full/, '未取完整地址');
assert.match(miniSrc, /this\.card && this\.card\.postal_code/, '未取邮编');
assert.match(miniSrc, /field === 'address'/, '未按 address 字段覆盖');
assert.match(miniSrc, /label: '邮编'/, '邮编未独立成行');
// rowsInfo 必须走 decorateRows(否则覆盖不生效)
assert.match(miniSrc, /rowsInfo\(\)\s*\{[\s\S]*?visibleRows\(this\.decorateRows\(this\.card\.rows\)/,
'rowsInfo 未走 decorateRows');
});
test('多语言: 请求带 lang 参数(后端按 _en 优先下发)', () => {
for (const f of ['diy-card-show.vue', 'diy-card-self.vue', 'diy-card-list.vue']) {
const src = read(f);
assert.match(src, /lang: uni\.getStorageSync\('lang'\) \|\| 'zh-cn'/,
f + ' 请求未带 lang 参数');
}
});

View File

@@ -0,0 +1,98 @@
/**
* 名片 DIY 组件 ·「区块顺序」契约测试(2.23, 2026-10-28)
*
* 背景: 简介 / 资料区 / 快捷操作 / 社交媒体 / 二维码 / 标签来源 六个「可独立成块」的
* 模块此前是固定顺序(且快捷操作有 above/below 开关、二维码有 rows/bottom 档位),
* 2.23 统一为**区块流**: 顺序存 c.blockOrder, C 端按序渲染, 面板用 ↑↓ 调整。
*
* 覆盖(锁三个踩过的坑):
* 1. 区块流顺序解析(blockSeq): 配置优先 + 缺失 token 补齐 + 缺省按历史位置推导
* 2. 模板结构: <template v-for> 内必须用**独立 v-if**(Vue2 的 v-else-if 链跨迭代串联)
* 且 **template 不能带 :key**(Vue2 报错 "cannot be keyed")
* 3. 位置开关下线: 快捷操作上/下由顺序推导; 二维码 head/corner 是锚点档, 不进流内
*
* 跑法: node --test tests/diy-card-block-order.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');
const strip = s => s
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
const miniSrc = strip(read('diy-card-mini.vue'));
test('区块流: 常量与顺序解析', () => {
assert.match(miniSrc, /const BLOCK_TOKENS = \['bio', 'rows', 'acts', 'social', 'qr', 'meta'\]/,
'缺少 BLOCK_TOKENS(六个可独立成块的模块)');
assert.match(miniSrc, /const DEFAULT_BLOCK_ORDER = 'bio,acts,rows,social,qr,meta'/,
'缺少默认顺序(快捷操作在资料区之上, 兼容历史 actsPosition=above)');
assert.match(miniSrc, /blockSeq\(\)\s*\{[\s\S]*?this\.c\.blockOrder/,
'blockSeq 未读 c.blockOrder');
// 缺省回落: 按历史 actsPosition 推导快捷操作在资料区上/下
assert.match(miniSrc, /if \(!list\.length\) \{[\s\S]*?normalizeActsPosition\(this\.c\.actsPosition\) === 'above'/,
'缺省顺序未按历史 actsPosition 推导');
// 未知 token 忽略 + 缺失 token 补齐
assert.match(miniSrc, /BLOCK_TOKENS\.indexOf\(token\) >= 0 && list\.indexOf\(token\) < 0/,
'未过滤未知/重复 token');
assert.match(miniSrc, /def\.forEach\(\(t\) => \{[\s\S]*?if \(at < 0\) list\.unshift\(t\); else list\.splice\(at, 0, t\);/,
'缺失 token 未按默认顺序补齐');
});
test('区块流: 位置开关由顺序推导(旧开关只作兜底)', () => {
assert.match(miniSrc, /actsAbove\(\)\s*\{\s*const i = this\.blockSeq\.indexOf\('acts'\);\s*const r = this\.blockSeq\.indexOf\('rows'\);[\s\S]*?return i < r;/,
'actsAbove 未由区块顺序推导');
// 二维码: head/corner 是锚点档, 其余(rows/bottom/flow)进流内
assert.match(miniSrc, /showQrFlow\(\)\s*\{[\s\S]*?'flow'[\s\S]*?'rows'[\s\S]*?'bottom'/,
'showQrFlow 未覆盖 flow/rows/bottom 三档');
// 右上角浮层让位: 仅当资料区是流内首块
assert.match(miniSrc, /if \(this\.blockSeq\[0\] !== 'rows'\) return \{\};/,
'corner 让位未按区块顺序判据收窄');
});
test('区块流模板: template v-for 内用独立 v-if, 且 template 不带 :key', () => {
// Vue2: <template v-for> 内 v-else-if 链会跨迭代串联 → 必须全部独立 v-if
assert.doesNotMatch(miniSrc, /v-else-if="blk/, 'template v-for 内不得使用 v-else-if 链');
// Vue2: <template> cannot be keyed → key 放在真实元素上
const tmpl = miniSrc.match(/<template v-for="blk in blockSeq"[^>]*>/g) || [];
assert.equal(tmpl.length, 1, '应只有一个区块流 template');
assert.doesNotMatch(tmpl[0] || '', /:key=/, 'template v-for 不得带 :key(Vue2 报错)');
// key 下移到真实元素(快捷操作有宫格/芯片两个元素 → 真实元素数 7)
const keyed = miniSrc.match(/v-if="blk === '(bio|acts|rows|qr|social|meta)'[^>]*:key="blk"/g) || [];
assert.ok(keyed.length >= 6, '六个块的真实元素都应带 :key="blk", 实际 ' + keyed.length);
for (const blk of ['bio', 'acts', 'rows', 'qr', 'social', 'meta']) {
assert.match(miniSrc, new RegExp('v-if="blk === \'' + blk + '\'[^>]*:key="blk"'),
'块 ' + blk + ' 的真实元素未带 :key');
}
});
test('区块流: 六个块都被渲染且样式各自独立', () => {
for (const [blk, cls] of [['bio', 'mini-bio'], ['acts', 'mini-acts'], ['rows', 'mini-rows'],
['social', 'mini-social'], ['qr', 'mini-qr'], ['meta', 'mini-meta']]) {
assert.match(miniSrc, new RegExp('v-if="blk === \'' + blk + '\''), '缺少块 ' + blk + ' 的渲染分支');
}
assert.match(miniSrc, /class="mini-bio" :style="bioFinalStyle"/, '简介块未绑定独立样式');
assert.match(miniSrc, /class="mini-social" :style="socialFinalStyle"/, '社交块未绑定独立样式');
});
test('区块流: 通用工厂复用于新块(不新写一套)', () => {
assert.match(miniSrc, /function blockIndependent\(c, prefix\)/, '缺少 blockIndependent 工厂');
assert.match(miniSrc, /function blockFinalStyle\(c, prefix\)/, '缺少 blockFinalStyle 工厂');
assert.match(miniSrc, /bioFinalStyle\(\)\s*\{\s*return blockFinalStyle\(this\.c, 'bio'\);/, '简介未走通用工厂');
assert.match(miniSrc, /socialFinalStyle\(\)\s*\{\s*return blockFinalStyle\(this\.c, 'social'\);/, '社交未走通用工厂');
});
test('二维码档位: 新增 flow(卡片流内)', () => {
const common = strip(read('diy-card-common.js'));
assert.match(common, /export const QR_POSITIONS = \['flow', 'rows', 'head', 'corner', 'bottom'\]/,
'QR_POSITIONS 缺 flow 档');
assert.match(common, /export const QR_SIZES = \{ flow: 240,/, 'QR_SIZES 缺 flow 尺寸');
});

View File

@@ -228,19 +228,44 @@ if (hasPanels) {
assert.ok(cfg.includes('"qrPriority":"card"'), 'config/diy_view.php 缺 qrPriority 默认值');
});
test('面板: 预览真实消费 qrPriority(4 处二维码挂点走分层 v-if)', () => {
test('面板: 预览真实消费 qrPriority(3 处二维码挂点走分层 v-if)', () => {
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
// 4 个挂点(head/corner/rows/bottom) 各一组分支:
// system 占位 + component 组件图 + component 名片图 + card 名片图 + card 组件图 + 兜底占位
const sys = (panel.match(/v-if="nc\.qrPriority === 'system'"/g) || []).length;
const comp = (panel.match(/v-else-if="nc\.qrPriority === 'component'/g) || []).length;
assert.equal(sys, 4, name + ' 面板 system 档分支数不对: ' + sys);
// component 档每挂点 2 支(组件图 + 名片图回落) = 8
assert.equal(comp, 8, name + ' 面板 component 档分支数不对: ' + comp);
// 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 内取值) = 16
// 每支出现 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, 16, name + ' 面板名片级图分支数不对: ' + cardImg);
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 + ' 档判定'
);
}
}
});

View File

@@ -204,12 +204,16 @@ test('mini: 卡片四向外边距与头部盒模型配置完整消费', () => {
test('mini: C端盒模型计算值——历史默认兼容且自定义值逐项生效', async () => {
const m = await import('../components-diy/diy-card-common.js');
const share = await import('../common/js/card-share.js');
// 2026-10-28: mini 组件新增了 '@/common/js/card-share.js' 的 import(分享带名片 ID),
// 原来只剥离 './diy-card-common.js' 一条 → 每加一个 import 就崩一次;
// 改为**通用剥离所有 import 语句**, 被剥离的标识符改为按需注入。
const script = miniSrc.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s*\{[\s\S]*?\}\s*from\s*'\.\/diy-card-common\.js';/, '')
.replace(/^\s*import\s+[^;]+;\s*$/gm, '')
.replace('export default {', 'module.exports = {');
const names = Object.keys(m);
const names = [...Object.keys(m), ...Object.keys(share)];
const moduleBox = { exports: {} };
new Function('module', ...names, script)(moduleBox, ...names.map(k => m[k]));
new Function('module', ...names, script)(moduleBox, ...names.map(k => (m[k] !== undefined ? m[k] : share[k])));
const computed = moduleBox.exports.computed;
const util = { objToStyle: o => o };
const card = { theme_color: '#119DFF', gradient_end: '#0E81D1', gradient_angle: 135 };
@@ -276,8 +280,9 @@ test('mini: qrBlockStyle 四档外观分支(多彩只装饰外围, 码黑白保
assert.ok(!miniSrc.includes("qrStyle === 'plain' ? style.background"));
// 2026-10-23 用户定稿: 外观衬底只包二维码图(qrImgStyle), 「扫码查看完整名片」tip 在衬底外无蓝底
assert.match(miniSrc, /class="qr-img" :style="qrImgStyle"/);
// 2.23: 挂点由 4(head/corner/rows/bottom)合并为 3(head 双分支 / corner / 卡片流内)
const tipBinds = miniSrc.match(/class="qr-tip" :style="labelStyle"/g) || [];
assert.equal(tipBinds.length, 5, '五档 tip 仍独立(不进衬底; 含 theme 头部块), 实际 ' + tipBinds.length);
assert.equal(tipBinds.length, 4, '三挂点 tip 仍独立(不进衬底; 含 theme 头部块双分支), 实际 ' + tipBinds.length);
});
test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => {
@@ -295,9 +300,9 @@ test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => {
test('mini: 扫码提示开关 + 四个挂点统一消费', () => {
assert.match(miniSrc, /showQrTip\(\)\s*\{\s*return flagOn\(this\.c\.qrTipShow,\s*true\)/);
// 二维码 tip 挂点: corner/rows/bottom 各 1 + head 档 2(head 双形态分支互斥渲染, 2.17)
// 2.23: tip 挂点 = corner 1 + 卡片流内 1 + head 档 2(head 双形态分支互斥渲染)
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
assert.equal(tips.length, 5, 'tip 挂点(4 档 + head 双分支各一), 实际 ' + tips.length);
assert.equal(tips.length, 4, 'tip 挂点(3 挂点 + head 双分支), 实际 ' + tips.length);
});
test('mini: cornerClearStyle 按实际尺寸反算让位(防遮挡, 纯数值不用 calc)', () => {
@@ -318,11 +323,12 @@ test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算'
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
});
test('mini: 二维码块样式绑定——corner/head 走 qrBlockStyle, rows/bottom 走 qrBlockFinalStyle(2.19.10 分卡合并)', () => {
test('mini: 二维码块样式绑定——corner/head 走 qrBlockStyle, 卡片流内走 qrBlockFinalStyle(2.23 合并 rows/bottom)', () => {
const direct = miniSrc.match(/:style="qrBlockStyle"/g) || [];
assert.equal(direct.length, 3, 'qrBlockStyle 直绑(corner 1 + head 双分支 2), 实际 ' + direct.length);
// 2.23: rows/bottom 两处合并为「卡片流内」一处(位置由区块顺序决定)
const final = miniSrc.match(/:style="qrBlockFinalStyle"/g) || [];
assert.equal(final.length, 2, 'qrBlockFinalStyle(rows/bottom 各 1), 实际 ' + final.length);
assert.equal(final.length, 1, 'qrBlockFinalStyle(卡片流内 1), 实际 ' + final.length);
});
// =============================================================