diff --git a/components-diy/diy-card-common.js b/components-diy/diy-card-common.js index bd71213..aff9d5d 100644 --- a/components-diy/diy-card-common.js +++ b/components-diy/diy-card-common.js @@ -143,6 +143,18 @@ export function rowIconFor(field) { return ROW_ICONS[String(field || '')] || ''; } +/** + * 资料行 CSS 描边图标(2.17.1): iconfont 无邮箱/网站字形, 同宫格口径 + * 用纯 view 画信封/线框地球(与 ACT_GRID_ICONS.css 同一套图形, 尺寸缩小) + */ +const ROW_CSS_ICONS = { + email: 'mail', + website: 'web', +}; +export function rowCssIconFor(field) { + return ROW_CSS_ICONS[String(field || '')] || ''; +} + /** * 资料行点击动作: 由服务端下发的 href 派生(tel:/mailto:/url/空) * tel: → 拨号 mailto: → 邮箱(H5) / 复制(其它端) @@ -192,3 +204,250 @@ export function qrTransform(offsetX, offsetY) { if (x === 0 && y === 0) return ''; return 'translate(' + (x * 2) + 'rpx, ' + (y * 2) + 'rpx)'; } + +/** + * 头部形态(2.17): plain 白卡头部(历史默认) / theme 主题渐变整块头部 + * theme 档: 渐变铺头部 + 装饰圆 + 姓名副行白字(headNameColor/headSubColor 可覆盖) + */ +export const HEAD_STYLES = ['plain', 'theme']; +export function normalizeHeadStyle(v) { + const s = String(v || '').trim(); + return HEAD_STYLES.indexOf(s) >= 0 ? s : 'plain'; +} + +/** + * 快捷操作布局(2.17): chips 文字胶囊(历史默认) / grid 图标宫格 + * grid 档图标: 有字形用 iconfont, 无字形退化为动作首字(与头像首字同构) + * 注: 小程序端 iconfont 暂无邮件/网站字形(iconfont.css 无 youxiang/wangzhan), + * 扩展字形后补此表即可, 无需改组件 + */ +export const ACTS_LAYOUTS = ['chips', 'grid']; +export function normalizeActsLayout(v) { + const s = String(v || '').trim(); + return ACTS_LAYOUTS.indexOf(s) >= 0 ? s : 'chips'; +} + +/** + * 宫格图标(2.18.1): 与 wap 公开页 detail 快捷操作同源的 SVG 线稿 + * (路径逐一取自 wap/view/index/detail.html quickGrid, 保证两端观感一致) + * mode=stroke: 描边线稿(颜色动态跟随配色档/每按钮定制) + * mode=fill: 实心填充(wx 微信绿 / save 通讯录橙 —— detail 同款固定色) + * 渲染: svgLineIconBg 生成 data-URI 作为 background-image(H5+小程序通用, + * WXSS 对 base64 data URI 的 background-image 原生支持) + */ +export const WAP_LINE_ICONS = { + tel: { + mode: 'stroke', + body: '', + }, + wx: { + mode: 'fill', + color: '#07C160', + body: '', + }, + mail: { + mode: 'stroke', + body: '', + }, + web: { + mode: 'stroke', + body: '', + }, + save: { + mode: 'stroke', + color: '#FF9500', + body: '', + }, +}; + +/** 纯 ASCII base64(SVG 路径/颜色均为 ASCII, 小程序无 btoa 自带实现) */ +const B64T = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +function b64ascii(s) { + let out = ''; + for (let i = 0; i < s.length; i += 3) { + const a = s.charCodeAt(i); + const b = s.charCodeAt(i + 1); + const c = s.charCodeAt(i + 2); + out += B64T[a >> 2] + + B64T[((a & 3) << 4) | ((isNaN(b) ? 0 : b) >> 4)] + + (isNaN(b) ? '=' : B64T[((b & 15) << 2) | ((isNaN(c) ? 0 : c) >> 6)]) + + (isNaN(c) ? '=' : B64T[c & 63]); + } + return out; +} + +/** + * 宫格 SVG 图标 → background-image 值(data URI, 颜色内嵌) + * color 仅对 stroke 模式生效; fill 模式用 WAP_LINE_ICONS 内置固定色 + */ +export function svgLineIconBg(type, color) { + const ico = WAP_LINE_ICONS[String(type || '')]; + if (!ico) return ''; + const attrs = ico.mode === 'fill' + ? 'fill="' + (ico.color || '#07C160') + '"' + : 'fill="none" stroke="' + (color || '#2F6BFF') + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; + const svg = '' + ico.body + ''; + return 'url("data:image/svg+xml;base64,' + b64ascii(svg) + '")'; +} + +/** + * 宫格配色(2.17): preset 多彩预设(参考图: 浅底+深字五色) / theme 主题色 + * theme 档浅底由 hexToRgba(theme, 0.12) 动态派生 —— WXSS 无 color-mix, JS 端换算 + */ +export const ACT_GRID_PRESETS = { + tel: { bg: '#E1F5EE', fg: '#0F6E56' }, + wx: { bg: '#EAF3DE', fg: '#3B6D11' }, + mail: { bg: '#E6F1FB', fg: '#185FA5' }, + web: { bg: '#FAEEDA', fg: '#854F0B' }, + save: { bg: '#F0E9FB', fg: '#5B3DA8' }, +}; +export const ACT_GRID_PRESET_FALLBACK = { bg: '#F2F3F5', fg: '#5F5E5A' }; + +export function normalizeActGridColorMode(v) { + const s = String(v || '').trim(); + return s === 'theme' ? 'theme' : 'preset'; +} + +/** + * 快捷操作区位置(2.18): below 资料区下方(历史默认) / above 资料区上方 + * 历史配置无此字段时回落 below —— 渲染零变化 + */ +export const ACTS_POSITIONS = ['below', 'above']; +export function normalizeActsPosition(v) { + const s = String(v || '').trim(); + return ACTS_POSITIONS.indexOf(s) >= 0 ? s : 'below'; +} + +/** + * 每按钮图标配置(2.18): actIcons = { type: { img, color, bg } } + * img 自定义图标图片(媒体库, 空=自动档: iconfont 字形 / CSS 描边图标) + * color 图标前景色(空=跟随 preset/theme 配色档) + * bg 图标浅底色(空=跟随 preset/theme 配色档) + * 仅 grid 宫格档消费; 任意字段留空即回落自动档(历史形态零变化) + */ +export function actIconCfgOf(actIcons, type) { + const cfg = actIcons && actIcons[String(type || '')]; + if (!cfg || typeof cfg !== 'object') return {}; + return cfg; +} + +/** hex(#RRGGBB / #RGB) → rgba 字符串(宫格主题浅底/复制按钮浅底用); 非法入参返回 null */ +export function hexToRgba(hex, alpha) { + const s = String(hex || '').trim().replace(/^#/, ''); + let full = ''; + if (/^[0-9a-fA-F]{6}$/.test(s)) { + full = s; + } else if (/^[0-9a-fA-F]{3}$/.test(s)) { + full = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; + } else { + return null; + } + const n = parseInt(full, 16); + return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')'; +} + +/* ------------------------------------------------------------------ + * 2.19 快捷操作: 显示控制 + 文案改名 + 自定义动作 + 素材图标库 + * ------------------------------------------------------------------ */ + +/** 默认动作类型序(图层面板行序与 C 端合并序一致) */ +export const ACT_TYPES = ['tel', 'wx', 'mail', 'web', 'save']; + +/** 自定义动作类型白名单(doAct 天然消费: tel/wx/mail/copy 分支 + else 兜底) */ +export const ACT_ACTION_TYPES = ['tel', 'wx', 'mail', 'web', 'copy']; + +export const ACT_LABEL_DEFAULTS = { + tel: '打电话', + wx: '微信号', + mail: '发邮件', + web: '网站', + save: '存通讯录', +}; + +/** 默认动作次序(2.19.1): 历史渲染顺序 = 后端 quickActs 下发顺序 */ +export const ACT_ORDER_DEFAULT = ['tel', 'wx', 'mail', 'web', 'save']; + +/** + * 默认动作次序(2.19.1): actOrder = [type...] —— 只收白名单内类型并去重, + * 缺失类型按默认顺序尾补(保证 5 类型齐全), 非数组/全非法 = 历史顺序 + */ +export function normalizeActOrder(order) { + const out = []; + (Array.isArray(order) ? order : []).forEach(k => { + const t = String(k || ''); + if (ACT_TYPES.indexOf(t) >= 0 && out.indexOf(t) < 0) out.push(t); + }); + ACT_ORDER_DEFAULT.forEach(t => { + if (out.indexOf(t) < 0) out.push(t); + }); + return out; +} + +/** + * 默认动作显隐(2.19): actVis = { type: 0|1 }, 缺键/非法 = 显示(1) + * 历史配置无此字段 → 全 1, 渲染零变化 + */ +export function normalizeActVis(vis) { + const src = vis && typeof vis === 'object' ? vis : {}; + const out = {}; + ACT_TYPES.forEach(t => { + out[t] = String(src[t]) === '0' ? 0 : 1; + }); + return out; +} + +/** + * 默认动作文案(2.19): actLabels = { type: string }, 空/缺 = 内置默认 + * 历史配置无此字段 → 全默认, 渲染零变化 + */ +export function normalizeActLabels(labels) { + const src = labels && typeof labels === 'object' ? labels : {}; + const out = {}; + ACT_TYPES.forEach(t => { + const s = String(src[t] || '').trim(); + out[t] = s || ACT_LABEL_DEFAULTS[t]; + }); + return out; +} + +/** + * 自定义动作列表(2.19): customActs = [{ label, type, value, iconKey, img, color, bg, vis }] + * 白名单过滤 + 字段收敛(防御脏配置); 非数组/空 → [] (历史形态零变化) + */ +export function normalizeCustomActs(list) { + if (!Array.isArray(list)) return []; + return list.slice(0, 10).map((raw, i) => { + const it = raw && typeof raw === 'object' ? raw : {}; + const type = ACT_ACTION_TYPES.indexOf(String(it.type || '')) >= 0 ? String(it.type) : 'copy'; + return { + label: String(it.label || '').trim().slice(0, 6) || '自定义', + type: type, + value: String(it.value || '').trim().slice(0, 200), + iconKey: String(it.iconKey || '').trim(), + img: String(it.img || '').trim(), + color: String(it.color || '').trim(), + bg: String(it.bg || '').trim(), + vis: String(it.vis) === '0' ? 0 : 1, + custom: 1, + _i: i, + }; + }).filter(it => it.label !== '自定义' || it.value || it.img || it.iconKey); +} +/** + * 2.19 素材图标库(C 端): 与后端 addon\card\service\CardIconLibrary 同源, + * 由 gen 脚本从 PHP 常量逐字节生成, 契约测试锁定同步。 + * key 图标渲染优先级: img(媒体库) > iconKey(本库, 实时配色) > WAP_LINE_ICONS > 首字 + */ +export const CARD_ICON_LIB = {"phone":"","sms":"","email":"","wechat":"","whatsapp":"","linkedin":"","weibo":"","shortvideo":"","web":"","location":"","qrcode":"","account":"","company":"","job":"","handshake":"","chart":"","store":"","calendar":"","document":"","shield":"","image":"","video":"","play":"","camera":"","music":"","star":"","heart":"","gift":"","pay":"","truck":"","book":"","share":"","settings":""}; + +/** 素材库图标 SVG 字符串(缺 key 返回空串) */ +export function libIconSvg(key) { + return CARD_ICON_LIB[String(key || '')] || ''; +} + +/** 素材库图标 → data URI background-image 值(小程序无 btoa, 复用 b64ascii) */ +export function libIconBg(key, color) { + const svg = libIconSvg(key); + if (!svg) return ''; + return 'url("data:image/svg+xml;base64,' + b64ascii(svg.split('currentColor').join(color || '#2F6BFF')) + '")'; +} diff --git a/components-diy/diy-card-mini.vue b/components-diy/diy-card-mini.vue index d756bdf..e0cb872 100644 --- a/components-diy/diy-card-mini.vue +++ b/components-diy/diy-card-mini.vue @@ -17,7 +17,7 @@ 💳 - {{ emptyTip || '暂无名片数据' }} + {{ emptyTip || '暂无名片数据' }} {{ emptyActionText }} @@ -41,11 +41,37 @@ - + {{ qrTip || '扫码查看完整名片' }} - + + + + + + + + {{ initialOf(card.name) }} + + + + {{ card.name || '未命名名片' }} + 企业 + + {{ card.card_title }} + {{ subLine }} + + + + + {{ qrTip || '扫码查看完整名片' }} + + + + + + {{ initialOf(card.name) }} @@ -60,18 +86,54 @@ - + {{ qrTip || '扫码查看完整名片' }} + + + + + + + + + {{ actInitialOf(act) }} + + {{ act.label }} + + + + + {{ act.label }} + + + - + + + + + {{ rowsTitle }} + - + + + + + + + + + + + {{ row.label }} {{ row.value }} + 复制 {{ expanded ? '收起资料' : '展开全部资料(' + hiddenCount + ')' }} @@ -80,12 +142,26 @@ - + {{ qrTip || '扫码查看完整名片' }} - - + + + + + + + + + {{ actInitialOf(act) }} + + {{ act.label }} + + + + + {{ act.label }} @@ -94,7 +170,7 @@ - + {{ qrTip || '扫码查看完整名片' }} @@ -111,7 +187,12 @@ // 消费名片自身设计资产: cover 封面 / logo / theme_color / 渐变末色与角度 / card_title import { CardTokens, px2rpx, flagOn, num, initialOf, avatarBg, themeGradient, visibleRows, - rowIconFor, rowActOf, normalizeQrPosition, normalizeQrStyle, resolveQrSize, qrTransform, QR_SIZES, + rowIconFor, rowCssIconFor, rowActOf, normalizeQrPosition, normalizeQrStyle, resolveQrSize, qrTransform, QR_SIZES, + normalizeHeadStyle, normalizeActsLayout, normalizeActGridColorMode, normalizeActsPosition, actIconCfgOf, + WAP_LINE_ICONS, svgLineIconBg, + ACT_GRID_PRESETS, ACT_GRID_PRESET_FALLBACK, hexToRgba, + // 2.19: 显隐/文案/自定义动作归一化 + 素材图标库(与后端 CardIconLibrary 同源) + normalizeActVis, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg, } from './diy-card-common.js'; export default { @@ -156,9 +237,17 @@ export default { // 资料行总开关(面板「显示资料行」); 历史配置无此字段时默认显示, 兼容 1.0 return flagOn(this.c.showRows, true); }, - /** 二维码总条件: 开关 + 名片有二维码地址 */ + /** + * 二维码总条件: 开关 + 有码源(自定义图 qrImage 优先, 回落名片码 qr_url) + */ showQrBase() { - return flagOn(this.c.showQr, false) && !!(this.card && this.card.qr_url); + return flagOn(this.c.showQr, false) && !!this.qrSrc; + }, + /** 二维码码源(2.18): 用户自定义图片(媒体库)优先, 空=名片自动生成的码 */ + qrSrc() { + const custom = String(this.c.qrImage || '').trim(); + if (custom) return custom; + return (this.card && this.card.qr_url) || ''; }, qrPos() { return normalizeQrPosition(this.c.qrPosition); @@ -190,7 +279,8 @@ export default { }, /** * corner 档资料行/操作区让位量: 按二维码实际占高反算 - * (浮层 top 20 + 块高[尺寸+样式衬底+tip] - 头部 104 + 间隙 10), 防遮挡; + * (浮层 top 20 + 块高[尺寸+样式衬底+tip] - 头部实际高 + 间隙 10), 防遮挡; + * 头部实际高: plain=104 / theme 档=32+104+28=164(上下 padding 增高, 2.17); * 自定义大尺寸/关 tip 时自动跟随(不用 calc, 纯数值) */ cornerClearStyle() { @@ -199,7 +289,8 @@ export default { const pad = this.qrStyle === 'plain' ? 0 : (this.qrStyle === 'gradient' ? 6 : 10) * 2; const tip = this.showQrTip ? 42 : 0; const total = 20 + size + pad + tip; - return { marginTop: Math.max(24, total - 104 + 10) + 'rpx' }; + const headHeight = this.headTheme ? 164 : 104; + return { marginTop: Math.max(24, total - headHeight + 10) + 'rpx' }; }, /** * 二维码块样式: 尺寸(自定义>档位自动) + 微调位移(transform, WXSS 支持; @@ -233,6 +324,37 @@ export default { showDetailBtn() { return flagOn(this.c.showDetailBtn, true); }, + /** 头部形态(2.17): theme=渐变整块头部(历史配置无此字段时为 plain, 兼容 1.0) */ + headTheme() { + return normalizeHeadStyle(this.c.headStyle) === 'theme'; + }, + /** + * theme 档头部块: 渐变通铺卡边(负 margin 抵消 wrapStyle 的 padding, WXSS 不用 calc) + * 内部左右 padding = 卡片原 padding, 保证头像与下方资料行左缘对齐 + */ + headThemeStyle() { + const pad = px2rpx(num(this.c.padding, CardTokens.space.lg)); + return this.$util.objToStyle({ + background: themeGradient(this.card), + margin: '-' + pad + 'rpx -' + pad + 'rpx 0', + padding: '32rpx ' + pad + 'rpx 28rpx', + }); + }, + /** 资料区标题(2.17): 默认关(历史配置无此字段时不出标题) */ + rowsTitleShow() { + return flagOn(this.c.showRowsTitle, false); + }, + rowsTitle() { + return String(this.c.rowsTitle || '联系资料'); + }, + /** 快捷操作布局(2.17): 默认 chips 胶囊(历史配置无此字段时不变) */ + actsGrid() { + return normalizeActsLayout(this.c.actsLayout) === 'grid'; + }, + /** 复制按钮(2.17): 默认关; 手机等资料行右侧浅底小按钮, 点击复制原文 */ + showCopyBtn() { + return flagOn(this.c.showCopyBtn, false); + }, qrTip() { return this.c.qrTip || ''; }, @@ -255,8 +377,56 @@ export default { hiddenCount() { return this.rowsInfo.rest.length; }, + /** + * 快捷操作(2.19 重构): 服务端 acts(资料驱动) + 组件配置合成 + * 1. actVis 显隐过滤(缺省全显, 历史形态零变化) + * 2. actLabels 文案覆盖(空/缺 = 服务端 label) + * 3. customActs 自定义动作尾接(vis=0 隐藏) + * 每项合并 cfgImg/cfgKey/cfgColor/cfgBg 四字段 —— 图标渲染统一走 + * 「媒体库图 > 素材库图标 > SVG 线稿 > 首字」优先级, 免去方法内重复取配置 + */ acts() { - return this.card && Array.isArray(this.card.acts) ? this.card.acts : []; + const raw = this.card && Array.isArray(this.card.acts) ? this.card.acts : []; + const vis = normalizeActVis(this.c.actVis); + const labels = normalizeActLabels(this.c.actLabels); + const icons = this.c.actIcons && typeof this.c.actIcons === 'object' ? this.c.actIcons : {}; + const order = normalizeActOrder(this.c.actOrder); + const merge = (a) => { + const t = String(a.type); + const cfg = icons[t] && typeof icons[t] === 'object' ? icons[t] : {}; + return Object.assign({}, a, { + label: labels[t] || a.label, + cfgImg: String(cfg.img || '').trim(), + cfgKey: String(cfg.iconKey || '').trim(), + cfgColor: String(cfg.color || '').trim(), + cfgBg: String(cfg.bg || '').trim(), + }); + }; + // 2.19.1: 按 actOrder 次序输出默认动作; 后端 quickActs 扩展类型(order 外)尾接兜底 + const byType = {}; + raw.forEach(a => { + if (a && a.type) byType[String(a.type)] = a; + }); + const out = []; + order.forEach(t => { + if (byType[t] && vis[t] !== 0) out.push(merge(byType[t])); + }); + raw.forEach(a => { + if (!a || !a.type) return; + const t = String(a.type); + if (order.indexOf(t) >= 0 || vis[t] === 0) return; + out.push(merge(a)); + }); + normalizeCustomActs(this.c.customActs).forEach(ca => { + if (ca.vis === 0) return; + out.push(Object.assign({}, ca, { + cfgImg: ca.img, + cfgKey: ca.iconKey, + cfgColor: ca.color, + cfgBg: ca.bg, + })); + }); + return out; }, isEnterprise() { return this.card && this.card.card_type === 'enterprise'; @@ -272,22 +442,27 @@ export default { return this.$util.objToStyle({ background: avatarBg(this.card) }); }, nameStyle() { + // theme 渐变头部档: 深底上默认白字(headNameColor 可覆盖); plain 档沿用历史配色 + const autoColor = this.headTheme ? (this.c.headNameColor || '#FFFFFF') : (this.c.nameColor || CardTokens.color.text); return this.$util.objToStyle({ - color: this.c.nameColor || CardTokens.color.text, + color: autoColor, fontSize: px2rpx(num(this.c.nameSize, 17)) + 'rpx', }); }, /** 名片头衔(设计器 card_title): 默认 13px = 26rpx, 与副行 24rpx 区分 */ cardTitleStyle() { + const autoColor = this.headTheme ? (this.c.headSubColor || 'rgba(255,255,255,0.9)') : (this.c.infoColor || CardTokens.color.textSub); return this.$util.objToStyle({ - color: this.c.infoColor || CardTokens.color.textSub, + color: autoColor, fontSize: px2rpx(num(this.c.cardTitleSize, 13)) + 'rpx', }); }, /** 副行(公司/职位)与空态提示共用: 默认 12px = 24rpx */ subTextStyle() { + // theme 档副行默认白 85% 透明度(渐变深底可读); plain 档沿用历史配色 + const autoColor = this.headTheme ? (this.c.headSubColor || 'rgba(255,255,255,0.85)') : (this.c.infoColor || CardTokens.color.textSub); return this.$util.objToStyle({ - color: this.c.infoColor || CardTokens.color.textSub, + color: autoColor, fontSize: px2rpx(num(this.c.subSize, 12)) + 'rpx', }); }, @@ -308,6 +483,33 @@ export default { fontSize: px2rpx(num(this.c.valueSize, 12)) + 'rpx', }); }, + /** 空态提示: 固定历史配色(不随 theme 头部变白 —— 空态无渐变头) */ + emptyTipStyle() { + return this.$util.objToStyle({ + color: this.c.infoColor || CardTokens.color.textSub, + fontSize: px2rpx(num(this.c.subSize, 12)) + 'rpx', + }); + }, + /** 资料区标题(2.17): 主题色竖条 + 主文字色标题 */ + rowsTitleBarStyle() { + const theme = (this.card && this.card.theme_color) || CardTokens.color.primary; + return this.$util.objToStyle({ background: theme }); + }, + rowsTitleStyle() { + return this.$util.objToStyle({ + color: this.c.nameColor || CardTokens.color.text, + fontSize: px2rpx(14) + 'rpx', + fontWeight: '600', + }); + }, + /** 复制按钮: 主题浅底 + 主题色字(hexToRgba 动态派生, WXSS 无 color-mix) */ + copyBtnStyle() { + const theme = (this.card && this.card.theme_color) || CardTokens.color.primary; + return this.$util.objToStyle({ + background: hexToRgba(theme, 0.12) || '#F2F3F5', + color: theme, + }); + }, badgeStyle() { return this.$util.objToStyle({ background: avatarBg(this.card) }); }, @@ -352,6 +554,36 @@ export default { fontSize: px2rpx(num(this.c.btnSize, 13)) + 'rpx', }); }, + /** 宫格配色模式(2.17): preset 多彩预设 / theme 主题色派生 */ + gridColorMode() { + return normalizeActGridColorMode(this.c.actGridColorMode); + }, + /** 宫格列宽: 5 个动作一行(存通讯录入列后), 4 个及以下仍四列均分 */ + gridItemWidth() { + return this.acts.length > 4 ? '20%' : '25%'; + }, + /** 快捷操作区位置(2.18): above=资料区上方(below 历史默认零变化) */ + actsAbove() { + return normalizeActsPosition(this.c.actsPosition) === 'above'; + }, + /** + * 快捷操作区独立块样式(2.18): 背景/圆角/内边距, 全为默认(历史形态)时输出空串零变化 + */ + actsBlockStyle() { + const c = this.c; + const bg = String(c.actsBlockBg || '').trim(); + const radius = num(c.actsBlockRadius, 0); + const pad = num(c.actsBlockPad, 0); + const style = {}; + if (bg) style.background = bg; + if (radius > 0) style.borderRadius = px2rpx(radius) + 'rpx'; + if (pad > 0) style.padding = px2rpx(pad) + 'rpx'; + return this.$util.objToStyle(style); + }, + /** 资料行让位: 操作区在上方时由操作区让位, 资料行不再重复位移 */ + rowsClearStyle() { + return this.actsAbove ? {} : this.cornerClearStyle; + }, detailBtnStyle() { return this.$util.objToStyle({ background: themeGradient(this.card), @@ -373,6 +605,20 @@ export default { rowIcon(row) { return flagOn(this.c.showRowIcons, true) ? rowIconFor(row && row.field) : ''; }, + /** 资料行 CSS 描边图标(2.17.1): 邮箱=信封 / 网站=线框地球, 同受行图标开关 */ + rowCssIcon(row) { + return flagOn(this.c.showRowIcons, true) ? rowCssIconFor(row && row.field) : ''; + }, + /** 资料行 CSS 图标颜色: 跟随 labelColor(与 iconfont 行图标同色口径) */ + rowCssColor() { + return this.c.labelColor || CardTokens.color.textLabel; + }, + rowCssBorderStyle() { + return this.$util.objToStyle({ borderColor: this.rowCssColor() }); + }, + rowCssFillStyle() { + return this.$util.objToStyle({ background: this.rowCssColor() }); + }, /** 资料行是否可点: 行点击开关 + 服务端 href 派生得出动作 */ rowTapable(row) { return flagOn(this.c.rowTap, true) && !!rowActOf(row); @@ -384,6 +630,75 @@ export default { onAct(act) { this.$emit('act', act); }, + /** 每按钮自定义图标图片(2.18 媒体库, 优先级最高; acts 已合并 cfgImg) */ + actImgOf(act) { + return String((act && act.cfgImg) || '').trim() || String(actIconCfgOf(this.c.actIcons, act && act.type).img || '').trim(); + }, + /** + * 素材库图标(2.19): acts 已合并 cfgKey(iconKey), 库缺 key 返回空串回落线稿档 + */ + actLibOf(act) { + return String((act && act.cfgKey) || '').trim(); + }, + /** 素材库图标背景(实时配色: color 空回落 actIconColor 四级口径) */ + actLibStyle(act) { + return this.$util.objToStyle({ backgroundImage: libIconBg(this.actLibOf(act), this.actIconColor(act)) }); + }, + /** 宫格 SVG 线稿(2.18.1): 与 wap detail 同源, 素材库/媒体库优先时不再进入 */ + actSvgOf(act) { + return !this.actImgOf(act) && !this.actLibOf(act) && !!WAP_LINE_ICONS[String((act && act.type) || '')]; + }, + /** + * 图标前景色: act 合并的每按钮 color > detail 同源固定色(wx 微信绿/save 通讯录橙) + * > theme 档主题色 > preset 档预设深字 + */ + actIconColor(act) { + const custom = String((act && act.cfgColor) || '').trim(); + if (custom) return custom; + const ico = WAP_LINE_ICONS[String((act && act.type) || '')]; + if (ico && ico.color) return ico.color; + if (this.gridColorMode === 'theme') { + return (this.card && this.card.theme_color) || CardTokens.color.primary; + } + const preset = ACT_GRID_PRESETS[(act && act.type) || ''] || ACT_GRID_PRESET_FALLBACK; + return preset.fg; + }, + /** SVG 线稿背景(data URI, 颜色内嵌) */ + actSvgStyle(act) { + return this.$util.objToStyle({ backgroundImage: svgLineIconBg(act && act.type, this.actIconColor(act)) }); + }, + actInitialOf(act) { + return String((act && act.label) || '·').charAt(0); + }, + /** 宫格配色(每按钮 bg 覆盖 / preset 多彩预设 / theme 主题渐变) —— 带参, 走 methods */ + actGridIconStyle(act) { + // 每按钮自定义底色优先(2.18), 空回落 preset/theme 档(acts 已合并 cfgBg) + const cfgBg = String((act && act.cfgBg) || '').trim() || String(actIconCfgOf(this.c.actIcons, act && act.type).bg || '').trim(); + if (cfgBg) return this.$util.objToStyle({ background: cfgBg }); + const theme = (this.card && this.card.theme_color) || CardTokens.color.primary; + if (this.gridColorMode === 'theme') { + // detail 同款浅主题渐变底(145deg, 12%→20% 主题色混白) + return this.$util.objToStyle({ + background: 'linear-gradient(145deg, ' + (hexToRgba(theme, 0.12) || '#F2F3F5') + ', ' + (hexToRgba(theme, 0.2) || '#F2F3F5') + ')', + }); + } + const preset = ACT_GRID_PRESETS[(act && act.type) || ''] || ACT_GRID_PRESET_FALLBACK; + return this.$util.objToStyle({ background: preset.bg }); + }, + /** 首字兜底字色(未知类型), 与每按钮 color/固定色同口径 */ + actGridGlyphStyle(act) { + return this.$util.objToStyle({ color: this.actIconColor(act) }); + }, + actGridLabelStyle() { + return this.$util.objToStyle({ + color: this.c.labelColor || CardTokens.color.textSub, + fontSize: px2rpx(num(this.c.actSize, 12)) + 'rpx', + }); + }, + /** 复制按钮: 复用 row-act 的 copy 分支(rowTap 关闭时仍可独立复制) */ + onCopyRow(row) { + this.$emit('row-act', { type: 'copy', value: row.value }); + }, onDetail() { this.$emit('detail', this.card); }, @@ -477,6 +792,123 @@ export default { } } + /* theme 渐变头部档: 白描边白底字头像更贴合深底(仅 theme 档, plain 零变化) */ + .mini-headtheme & { + text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10); + } + } + + /* 头部·主题渐变整块档(2.17): 通铺卡边 + 装饰圆; 颜色固定白透明度, 任意主题色可见 + 圆角只给底部 —— 顶部贴卡边, 避免与卡片圆角叠加出双圆角 */ + .mini-headtheme { + position: relative; + z-index: 2; + overflow: hidden; + border-radius: 0 0 16rpx 16rpx; + + .headtheme-decor { + position: absolute; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + pointer-events: none; + + &--big { + width: 240rpx; + height: 240rpx; + right: -60rpx; + top: -80rpx; + } + + &--small { + width: 120rpx; + height: 120rpx; + left: -40rpx; + bottom: -50rpx; + background: rgba(255, 255, 255, 0.08); + } + } + } + + /* 资料区标题(2.17): 主题色竖条(尺寸内联) + 600 字重标题 */ + .mini-rows-title { + display: flex; + align-items: center; + margin-bottom: 16rpx; + + .rows-title-bar { + flex-shrink: 0; + width: 8rpx; + height: 28rpx; + border-radius: 4rpx; + margin-right: 12rpx; + } + } + + /* 复制按钮(2.17): 浅底主题色小按钮(色内联), 不挤压资料值换行 */ + .row-copy { + flex-shrink: 0; + margin-left: 16rpx; + padding: 4rpx 20rpx; + border-radius: 999rpx; + font-size: 22rpx; + line-height: 1.4; + } + + /* 快捷操作·图标宫格档(2.17): 均分多列, 图标浅底块 88rpx + 列宽由内联 gridItemWidth 动态给出(5 动作 20% / 4 及以下 25%), 此处为兜底 */ + .mini-acts-grid { + position: relative; + z-index: 2; + margin-top: 24rpx; + display: flex; + flex-wrap: wrap; + + .act-grid-item { + width: 25%; + box-sizing: border-box; + padding: 8rpx 0; + display: flex; + flex-direction: column; + align-items: center; + + .act-grid-icon { + width: 88rpx; + height: 88rpx; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + + /* SVG 线稿图标(2.18.1): data URI 背景呈现, 与 wap detail 同源 */ + .act-grid-svg { + width: 44rpx; + height: 44rpx; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + } + + /* 自定义图标图片(2.18): 铺满图标块, 圆角继承 */ + .act-grid-img { + width: 100%; + height: 100%; + display: block; + border-radius: inherit; + } + + .act-grid-initial { + font-size: 36rpx; + font-weight: 600; + } + } + + .act-grid-label { + margin-top: 12rpx; + line-height: 1.4; + } + } + } + .mini-head-info { flex: 1; min-width: 0; @@ -533,6 +965,46 @@ export default { display: flex; line-height: 2; + /* CSS 描边图标容器(2.17.1): 与 iconfont 图标列/文字标签列同宽 96rpx, 值列左缘对齐 */ + .row-css-wrap { + flex-shrink: 0; + width: 96rpx; + display: flex; + align-items: center; + justify-content: center; + align-self: center; + } + + /* 行内缩小版信封/地球(宫格同构图形, 边框色内联) */ + .css-ico-mail--row { + width: 30rpx; + height: 22rpx; + border-width: 3rpx; + border-radius: 4rpx; + + .css-ico-mail-flap { + width: 10rpx; + height: 10rpx; + border-width: 3rpx; + top: -2rpx; + } + } + + .css-ico-web--row { + width: 26rpx; + height: 26rpx; + border-width: 3rpx; + + .css-ico-web-h { + height: 3rpx; + } + + .css-ico-web-v { + width: 10rpx; + border-width: 3rpx; + } + } + .row-label { flex-shrink: 0; width: 96rpx; @@ -746,6 +1218,63 @@ export default { transform: scale(0.98); } +/* CSS 描边图标(2.17.1): iconfont 无邮件/网站字形, 纯 view 边框绘制, + 颜色由内联 style 跟随 preset/theme 配色档(H5+小程序通用)。 + 顶层定义 —— 宫格(88rpx 图标块内)与资料行(--row 缩小版)共用同一套图形 */ +.css-ico-mail { + position: relative; + width: 44rpx; + height: 32rpx; + border: 4rpx solid #B0B4BC; + border-radius: 6rpx; + box-sizing: border-box; + + /* 封面折角: 只留左+下边的方块旋转 -45° 成 V 形折线 */ + .css-ico-mail-flap { + position: absolute; + left: 50%; + top: -2rpx; + width: 16rpx; + height: 16rpx; + border-left: 4rpx solid #B0B4BC; + border-bottom: 4rpx solid #B0B4BC; + transform: translateX(-50%) rotate(-45deg); + } +} + +.css-ico-web { + position: relative; + width: 40rpx; + height: 40rpx; + border: 4rpx solid #B0B4BC; + border-radius: 50%; + box-sizing: border-box; + overflow: hidden; + + /* 赤道横线(色由内联 background 给出) */ + .css-ico-web-h { + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: 4rpx; + transform: translateY(-50%); + } + + /* 经线椭圆: 左右双边框 + 椭圆圆角 */ + .css-ico-web-v { + position: absolute; + left: 50%; + top: -2rpx; + bottom: -2rpx; + width: 14rpx; + transform: translateX(-50%); + border-left: 4rpx solid #B0B4BC; + border-right: 4rpx solid #B0B4BC; + border-radius: 50%; + } +} + @keyframes sk-shimmer { 0% { background-position: -200rpx 0; diff --git a/components-diy/diy-card-self.vue b/components-diy/diy-card-self.vue index 487ffec..f09e17e 100644 --- a/components-diy/diy-card-self.vue +++ b/components-diy/diy-card-self.vue @@ -105,6 +105,27 @@ export default { qrOffsetX: v.qrOffsetX, qrOffsetY: v.qrOffsetY, qrTipShow: v.qrTipShow, + // 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮 + headStyle: v.headStyle, + headNameColor: v.headNameColor, + headSubColor: v.headSubColor, + actsLayout: v.actsLayout, + actGridColorMode: v.actGridColorMode, + showRowsTitle: v.showRowsTitle, + rowsTitle: v.rowsTitle, + showCopyBtn: v.showCopyBtn, + // 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图 + actsPosition: v.actsPosition, + actsBlockBg: v.actsBlockBg, + actsBlockRadius: v.actsBlockRadius, + actsBlockPad: v.actsBlockPad, + actIcons: v.actIcons, + qrImage: v.qrImage, + // 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成) + actVis: v.actVis, + actLabels: v.actLabels, + actOrder: v.actOrder, + customActs: v.customActs, }; }, boxStyle() { @@ -189,7 +210,13 @@ export default { this.$util.redirectTo('/pages_tool/card/list'); }, doAct(act) { - if (!act || !act.value) return; + if (!act) return; + // save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径) + if (act.type === 'save') { + this.goSaveContact(); + return; + } + if (!act.value) return; if (act.type === 'tel') { uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} }); } else if (act.type === 'wx') { @@ -218,6 +245,37 @@ export default { if (!card || !card.card_id) return; this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id }); }, + /** + * 存通讯录(2.17.1): 与 diy-card-show 同构 —— 字段全部从 card 组装 + * (rows 已过 shouldShow 白名单), 键名与 save-contact.vue onLoad 的 keys 一一对应。 + */ + goSaveContact() { + const c = this.card || {}; + const map = {}; + (Array.isArray(c.rows) ? c.rows : []).forEach(r => { + if (r && r.field) map[r.field] = String(r.value || ''); + }); + // #ifdef H5 + // H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页) + const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' '); + uni.setClipboardData({ + data: text, + success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }), + }); + return; + // #endif + this.$util.redirectTo('/pages_tool/card/save-contact', { + name: c.name || '', + mobile: map.mobile || '', + company: c.company || '', + job_title: c.job_title || '', + email: map.email || '', + website: map.website || '', + address: map.address || '', + remark: map.remark || '', + wechat: map.wechat || '', + }); + }, }, }; diff --git a/components-diy/diy-card-show.vue b/components-diy/diy-card-show.vue index f1deaab..7ca70b7 100644 --- a/components-diy/diy-card-show.vue +++ b/components-diy/diy-card-show.vue @@ -36,6 +36,15 @@ export default { labelColor: v.labelColor, borderRadius: v.borderRadius, padding: v.padding, + // 字号定制(px 口径, 缺省时基件按历史默认值渲染) —— 2.17 补齐: + // 此前 show 漏透传 7 个字号字段(面板配置不生效), 与 self 对齐 + nameSize: v.nameSize, + cardTitleSize: v.cardTitleSize, + subSize: v.subSize, + labelSize: v.labelSize, + valueSize: v.valueSize, + actSize: v.actSize, + btnSize: v.btnSize, showQuick: v.showQuick, showDetailBtn: v.showDetailBtn, detailBtnText: v.detailBtnText, @@ -59,6 +68,27 @@ export default { qrOffsetX: v.qrOffsetX, qrOffsetY: v.qrOffsetY, qrTipShow: v.qrTipShow, + // 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮 + headStyle: v.headStyle, + headNameColor: v.headNameColor, + headSubColor: v.headSubColor, + actsLayout: v.actsLayout, + actGridColorMode: v.actGridColorMode, + showRowsTitle: v.showRowsTitle, + rowsTitle: v.rowsTitle, + showCopyBtn: v.showCopyBtn, + // 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图 + actsPosition: v.actsPosition, + actsBlockBg: v.actsBlockBg, + actsBlockRadius: v.actsBlockRadius, + actsBlockPad: v.actsBlockPad, + actIcons: v.actIcons, + qrImage: v.qrImage, + // 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成) + actVis: v.actVis, + actLabels: v.actLabels, + actOrder: v.actOrder, + customActs: v.customActs, }; }, }, @@ -92,7 +122,13 @@ export default { }); }, doAct(act) { - if (!act || !act.value) return; + if (!act) return; + // save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径) + if (act.type === 'save') { + this.goSaveContact(); + return; + } + if (!act.value) return; if (act.type === 'tel') { uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} }); } else if (act.type === 'wx') { @@ -121,6 +157,39 @@ export default { if (!card || !card.card_id) return; this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id }); }, + /** + * 存通讯录(2.17.1): 字段全部从 card 组装 —— rows 已过 shouldShow + * 白名单(商家隐藏的字段不下发也不落通讯录, 隐私口径与后端一致), + * name/company/job_title 为公开头部字段直接取。 + * 键名与 save-contact.vue onLoad 的 keys 一一对应。 + */ + goSaveContact() { + const c = this.card || {}; + const map = {}; + (Array.isArray(c.rows) ? c.rows : []).forEach(r => { + if (r && r.field) map[r.field] = String(r.value || ''); + }); + // #ifdef H5 + // H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页) + const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' '); + uni.setClipboardData({ + data: text, + success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }), + }); + return; + // #endif + this.$util.redirectTo('/pages_tool/card/save-contact', { + name: c.name || '', + mobile: map.mobile || '', + company: c.company || '', + job_title: c.job_title || '', + email: map.email || '', + website: map.website || '', + address: map.address || '', + remark: map.remark || '', + wechat: map.wechat || '', + }); + }, }, }; diff --git a/tests/diy-card-ab-visual.test.js b/tests/diy-card-ab-visual.test.js index 10b23a3..7664107 100644 --- a/tests/diy-card-ab-visual.test.js +++ b/tests/diy-card-ab-visual.test.js @@ -44,12 +44,29 @@ test('common: rowIconFor 字段→图标映射(无字形字段退化为空)', as assert.equal(m.rowIconFor('phone'), 'icon-dianhua'); assert.equal(m.rowIconFor('wechat'), 'icon-weixin'); assert.equal(m.rowIconFor('address'), 'icon-dizhi'); - // 无字形: 邮箱/网站/自定义/未知 → 空串(模板退化为文字标签) + // 无字形: 邮箱/网站/自定义/未知 → 空串(模板走 CSS 描边图标或文字标签档) assert.equal(m.rowIconFor('email'), ''); assert.equal(m.rowIconFor('website'), ''); assert.equal(m.rowIconFor('custom'), ''); assert.equal(m.rowIconFor(''), ''); assert.equal(m.rowIconFor(undefined), ''); + // 2.17.1: rowCssIconFor —— 邮箱/网站走 CSS 描边图标(信封/线框地球), 口径互补 + assert.equal(m.rowCssIconFor('email'), 'mail'); + assert.equal(m.rowCssIconFor('website'), 'web'); + assert.equal(m.rowCssIconFor('mobile'), '', '有 iconfont 字形的字段不走 CSS 档'); + assert.equal(m.rowCssIconFor(''), ''); + assert.equal(m.rowCssIconFor(undefined), ''); + // 2.18.1: 宫格图标 = WAP_LINE_ICONS(与 wap detail 快捷操作同源 SVG 线稿), wx/save 固定色 + assert.equal(m.WAP_LINE_ICONS.tel.mode, 'stroke'); + assert.equal(m.WAP_LINE_ICONS.wx.mode, 'fill'); + assert.equal(m.WAP_LINE_ICONS.wx.color, '#07C160', 'wx 微信绿固定色'); + assert.equal(m.WAP_LINE_ICONS.save.color, '#FF9500', 'save 通讯录橙固定色'); + assert.equal(m.WAP_LINE_ICONS.mail.mode, 'stroke'); + assert.equal(m.WAP_LINE_ICONS.web.mode, 'stroke'); + assert.ok(m.svgLineIconBg('unknown', '#000') === '', '未知类型空串'); + assert.ok(m.svgLineIconBg('tel', '#0F6E56').indexOf('base64,') > 0, 'data URI base64 形态'); + // 宫格 preset 补 save 紫色档 + assert.deepEqual(m.ACT_GRID_PRESETS.save, { bg: '#F0E9FB', fg: '#5B3DA8' }); }); test('common: rowActOf 由 href 派生点击动作', async () => { @@ -80,10 +97,13 @@ test('common: 二维码四档归一化 + 尺寸表', async () => { // ============================================================= test('mini: 资料行图标列 + 文字标签退化', () => { - assert.match(miniSrc, //); + assert.match(miniSrc, / { @@ -103,8 +123,9 @@ test('mini: 二维码四档渲染分支齐全', () => { assert.match(miniSrc, /v-if="showQrHead" class="mini-qr mini-qr-head"/); assert.match(miniSrc, /v-if="showQrRows" class="mini-qr"/); assert.match(miniSrc, /v-if="showQrBottom" class="mini-qr"/); - // 总条件仍要求名片有 qr_url - assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!\(this\.card && this\.card\.qr_url\)/); + // 总条件仍要求有码源(2.18: 自定义图 qrImage 优先, 回落名片码) + assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/); + assert.match(miniSrc, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)[\s\S]*?card && this\.card\.qr_url/); }); test('mini: corner 档 Logo 避让 + 资料行/操作区动态让位', () => { @@ -113,7 +134,9 @@ test('mini: corner 档 Logo 避让 + 资料行/操作区动态让位', () => { // 头部水平让位 = cornerHeadStyle 动态反算(审计修复: 静态 180rpx 在自定义大尺寸下不足) assert.match(miniSrc, //); // 资料行/操作区让位 = cornerClearStyle 动态反算(自定义大尺寸/关 tip 自动跟随, 不用 calc) - assert.match(miniSrc, /:style="cornerClearStyle"/); + // 2.18: 让位经 rowsClearStyle 分流(操作区在上方时资料行不重复位移); 操作区为 独立块样式+让位 合并绑定 + assert.match(miniSrc, /rowsClearStyle\(\)\s*\{[\s\S]*?this\.actsAbove \? \{\} : this\.cornerClearStyle/); + assert.match(miniSrc, /:style="\[actsBlockStyle, cornerClearStyle\]"/); // 静态 CSS 让位必须已移除(动态值才是单一事实来源) assert.ok(!/padding-right: 180rpx;/.test(miniSrc), '静态 180rpx 让位应废弃, 改由 cornerHeadStyle 动态给出'); // 浮层 CSS 只定位于层次; 视觉衬底(白卡等)由 qrBlockStyle 内联决定 diff --git a/tests/diy-card-link-target.test.js b/tests/diy-card-link-target.test.js new file mode 100644 index 0000000..bc43f22 --- /dev/null +++ b/tests/diy-card-link-target.test.js @@ -0,0 +1,139 @@ +/** + * 名片 DIY 组件 · 跳转目标(含平台统一「系统链接」)链路测试 + * + * 背景: + * 商家在设计面板给「名片入口横幅 / 团队名片墙」配跳转目标, 有四档: + * list → 名片夹列表(内置) + * contacts→ 我的联系人(内置) + * custom → 自定义站内路径 + * link → 平台统一「系统链接」(商品/分类/微页面/小程序/拨号/自定义外链…) + * 第四档由后台的 nc-link 组件写入一个 link 对象 + * {name,title,wap_url,page_key,page_params}, C 端必须走 $util.diyRedirectTo + * 交给平台统一处理(内部页/外链/小程序/拨号), 不能自己拼 URL。 + * + * 覆盖: + * 1. entry/list 的 link 模式走 diyRedirectTo(而不是 redirectTo) + * 2. 未选链接时有守卫(不跳空) + * 3. 内置三档仍走 redirectTo(没被新分支吃掉) + * 4. 反例: 不存在虚构模式/虚构字段 + * + * 跑法: node --test tests/diy-card-link-target.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'); + +/** 剔除三类注释: HTML / 块 /* *\/ / 行 // (保护 https:// 里的 //) */ +function stripComments(src) { + return src + .replace(//g, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +const entrySrc = stripComments(read('diy-card-entry.vue')); +const listSrc = stripComments(read('diy-card-list.vue')); + +/** 取某个方法体(从方法名到下一个同级方法定义) */ +function methodBody(src, name) { + const start = src.indexOf(name + '('); + if (start === -1) return ''; + // 从 '{' 开始做括号配平 + let i = src.indexOf('{', start); + if (i === -1) return ''; + let depth = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === '{') depth++; + else if (src[j] === '}') { + depth--; + if (depth === 0) return src.slice(start, j + 1); + } + } + return src.slice(start); +} + +const entryGoLink = methodBody(entrySrc, 'goLink'); +const listGoMore = methodBody(listSrc, 'goMore'); + +// ============================================================= +// 1. link 模式走平台统一跳转 +// ============================================================= + +test('entry: goLink 的 link 模式走 $util.diyRedirectTo(平台统一处理链接对象)', () => { + assert.match(entryGoLink, /linkMode\s*===\s*'link'/); + assert.match(entryGoLink, /this\.\$util\.diyRedirectTo\(link\)/); + // link 模式必须**提前 return**, 不能落到下面的 redirectTo(url) + const linkBranch = entryGoLink.slice(entryGoLink.indexOf("linkMode === 'link'")); + assert.match(linkBranch.slice(0, 400), /return;/); +}); + +test('list: goMore 的 link 模式走 $util.diyRedirectTo', () => { + // list 的写法是先归一 mode = String(v.moreLinkMode || 'list'), 再按 mode 分支 + assert.match(listGoMore, /const\s+mode\s*=\s*String\(this\.v\.moreLinkMode\s*\|\|\s*'list'\)/); + assert.match(listGoMore, /mode\s*===\s*'link'/); + assert.match(listGoMore, /this\.\$util\.diyRedirectTo\(link\)/); + const linkBranch = listGoMore.slice(listGoMore.indexOf("mode === 'link'")); + assert.match(linkBranch.slice(0, 400), /return;/); +}); + +test('entry: link 取的是配置里的 link 对象(不是 wap_url 字符串)', () => { + assert.match(entryGoLink, /v\.link\s*&&\s*typeof\s+v\.link\s*===\s*'object'/); +}); + +test('list: link 取的是配置里的 moreLink 对象', () => { + assert.match(listGoMore, /this\.v\.moreLink\s*&&\s*typeof\s+this\.v\.moreLink\s*===\s*'object'/); +}); + +// ============================================================= +// 2. 未选链接的守卫(不跳空) +// ============================================================= + +test('entry: 未选链接时提示而不是跳空', () => { + assert.match(entryGoLink, /if\s*\(!link\s*\|\|\s*!link\.name\)/); + assert.match(entryGoLink, /请先选择链接/); +}); + +test('list: 未选链接时提示而不是跳空', () => { + assert.match(listGoMore, /if\s*\(!link\s*\|\|\s*!link\.name\)/); + assert.match(listGoMore, /请先选择链接/); +}); + +// ============================================================= +// 3. 内置三档仍走 redirectTo(没被新分支吃掉) +// ============================================================= + +test('entry: 内置三档仍是 redirectTo(名片夹/联系人/自定义路径)', () => { + assert.match(entryGoLink, /linkMode\s*===\s*'contacts'/); + assert.match(entryGoLink, /\/pages_tool\/card\/contacts/); + assert.match(entryGoLink, /linkMode\s*===\s*'custom'/); + assert.match(entryGoLink, /\/pages_tool\/card\/list/); + assert.match(entryGoLink, /this\.\$util\.redirectTo\(url\)/); +}); + +test('list: 内置三档仍是 redirectTo', () => { + assert.match(listGoMore, /mode\s*===\s*'contacts'/); + assert.match(listGoMore, /\/pages_tool\/card\/contacts/); + assert.match(listGoMore, /mode\s*===\s*'custom'/); + assert.match(listGoMore, /this\.\$util\.redirectTo\(url\)/); +}); + +// ============================================================= +// 反例自测(防恒真): 虚构模式/虚构字段必须不存在 +// ============================================================= + +test('反例: 两个组件都不存在虚构跳转模式', () => { + assert.ok(!entrySrc.includes("linkMode === 'nosuchmode'")); + assert.ok(!listSrc.includes("moreLinkMode === 'nosuchmode'")); +}); + +test('反例: 两个组件都不存在虚构链接字段', () => { + assert.ok(!entrySrc.includes('v.linkMagic')); + assert.ok(!listSrc.includes('v.moreLinkMagic')); +}); diff --git a/tests/diy-card-qr-style.test.js b/tests/diy-card-qr-style.test.js index 2415f56..2d84da5 100644 --- a/tests/diy-card-qr-style.test.js +++ b/tests/diy-card-qr-style.test.js @@ -39,6 +39,20 @@ const miniSrc = strip(read('diy-card-mini.vue')); const showPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8')); const selfPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8')); +/** + * 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组, + * 数字框 → 滑块): + * ① v-model="nc.X" 输入框 / 开关 + * ② nc.X = 'v' 可视化选项组(芯片)的赋值绑定 + * ③ field: 'X' 滑块控件 :data="{field:'X'}" + * 断言的是「这个字段确实被某个控件绑定了」, 不限定控件形态 —— 契约是字段名, 不是控件类型。 + */ +const bound = (src, field) => new RegExp( + 'v-model(\\.[a-z]+)?="nc\\.' + field + '"' + + '|\\bnc\\.' + field + "\\s*=\\s*['\"]" + + '|field\\s*:\\s*[\'"]' + field + '[\'"]' +).test(src); + // ============================================================= // 1. common.js 纯函数(真值断言) // ============================================================= @@ -140,15 +154,15 @@ test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => { test('mini: 扫码提示开关 + 四个挂点统一消费', () => { assert.match(miniSrc, /showQrTip\(\)\s*\{\s*return flagOn\(this\.c\.qrTipShow,\s*true\)/); - // 四个二维码块(corner/head/rows/bottom)各有一个 tip, 都受开关控制 + // 二维码 tip 挂点: corner/rows/bottom 各 1 + head 档 2(head 双形态分支互斥渲染, 2.17) const tips = miniSrc.match(/ { assert.match(miniSrc, /cornerClearStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/); // 让位量 = 浮层 top 20 + 块高[尺寸+衬底+tip] - 头部 104 + 间隙 10; 下限 24 - assert.match(miniSrc, /Math\.max\(24, total - 104 \+ 10\)/); + assert.match(miniSrc, /Math\.max\(24, total - headHeight \+ 10\)/); // 衬底与 tip 参与计算(关 tip 时让位自动收窄) assert.match(miniSrc, /const tip = this\.showQrTip \? 42 : 0;/); }); @@ -164,7 +178,7 @@ test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算' test('mini: 四个二维码块统一绑 qrBlockStyle(尺寸/位移/外观单一事实来源)', () => { const binds = miniSrc.match(/:style="qrBlockStyle"/g) || []; - assert.equal(binds.length, 4, 'corner/head/rows/bottom 四块, 实际 ' + binds.length); + assert.equal(binds.length, 5, 'qrBlockStyle 绑定(corner/rows/bottom 各 1 + head 双分支各一), 实际 ' + binds.length); }); // ============================================================= @@ -187,16 +201,16 @@ for (const [name, src] of [['show', showSrc], ['self', selfSrc]]) { // ============================================================= for (const [name, panel] of [['show', showPanel], ['self', selfPanel]]) { - test(name + ' 面板: 头像开关 + 二维码新六字段有 v-model 绑定', () => { - assert.match(panel, /v-model="nc\.showAvatar"/); - assert.match(panel, /v-model="nc\.qrStyle"/); - assert.match(panel, /v-model\.number="nc\.qrSize"/); - assert.match(panel, /v-model\.number="nc\.qrOffsetX"/); - assert.match(panel, /v-model\.number="nc\.qrOffsetY"/); - assert.match(panel, /v-model="nc\.qrTipShow"/); - // 二维码外观四选项(plain/card/theme/gradient) + test(name + ' 面板: 头像开关 + 二维码新六字段有控件绑定', () => { + for (const f of ['showAvatar', 'qrStyle', 'qrSize', 'qrOffsetX', 'qrOffsetY', 'qrTipShow']) { + assert.ok(bound(panel, f), '面板未绑定字段 ' + f); + } + // 二维码外观四档(plain/card/theme/gradient)都要能在面板里选到 for (const v of ['plain', 'card', 'theme', 'gradient']) { - assert.ok(panel.includes('