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:
164
common/js/card-share.js
Normal file
164
common/js/card-share.js
Normal 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) : '';
|
||||
}
|
||||
@@ -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) || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 主题色底 /
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\}`;/);
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
|
||||
329
tests/card-share-card-id.test.js
Normal file
329
tests/card-share-card-id.test.js
Normal 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/);
|
||||
});
|
||||
@@ -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: 自定义图优先, 回落名片码)
|
||||
|
||||
106
tests/diy-card-bio-social.test.js
Normal file
106
tests/diy-card-bio-social.test.js
Normal 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 参数');
|
||||
}
|
||||
});
|
||||
98
tests/diy-card-block-order.test.js
Normal file
98
tests/diy-card-block-order.test.js
Normal 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 尺寸');
|
||||
});
|
||||
@@ -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 + ' 档判定'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
|
||||
Reference in New Issue
Block a user