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

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;