diff --git a/common/js/card-share.js b/common/js/card-share.js new file mode 100644 index 0000000..39642fd --- /dev/null +++ b/common/js/card-share.js @@ -0,0 +1,164 @@ +/** + * 名片分享 · 跨端共享工具(小程序 / H5 / 鸿蒙通用) + * + * 背景(2026-10-28): + * 「我的名片」组件(components-diy/diy-card-self)的「发送名片」按钮在小程序端是 + * @@ -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 语义: BlockBg / BlockRadius / 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; diff --git a/components-diy/diy-card-self.vue b/components-diy/diy-card-self.vue index a664e84..1b04696 100644 --- a/components-diy/diy-card-self.vue +++ b/components-diy/diy-card-self.vue @@ -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; diff --git a/components-diy/diy-card-show.vue b/components-diy/diy-card-show.vue index 0a3229d..4a0da06 100644 --- a/components-diy/diy-card-show.vue +++ b/components-diy/diy-card-show.vue @@ -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; diff --git a/tests/card-mini-code-scene.test.js b/tests/card-mini-code-scene.test.js index 3edf114..9e88a6a 100644 --- a/tests/card-mini-code-scene.test.js +++ b/tests/card-mini-code-scene.test.js @@ -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\}`;/); }); // ================================================================ diff --git a/tests/card-share-card-id.test.js b/tests/card-share-card-id.test.js new file mode 100644 index 0000000..1b599cc --- /dev/null +++ b/tests/card-share-card-id.test.js @@ -0,0 +1,329 @@ +/** + * 名片分享带卡 ID · 前端契约测试(2026-10-28) + * + * 背景: + * 小程序端「我的名片」组件(diy-card-self)的「发送名片」按钮是