/** * 名片 DIY 组件 · 共享设计系统层 * * 目标(UI Designer 设计系统先行原则): * - 4 个组件共用同一套 token(颜色/字阶/间距/圆角/阴影/动效时长) * - 通用 helpers(px2rpx/头像兜底色/显隐开关解析)集中一处, 避免组件间漂移 * - 严格遵循小程序规范: 禁 CSS 变量, 动态样式一律 $util.objToStyle, 单位 rpx * * 注: 本文件为纯 JS(无 wxss), 小程序/H5 通用。 */ export const CardTokens = { color: { primary: '#2F6BFF', // 主题默认蓝(名片 wap 公开页同色系) text: '#1F2329', // 主文字 textSub: '#8A8F99', // 次文字(公司/职位) textLabel: '#B0B4BC', // 字段标签 line: '#EEF0F3', // 分割线 bg: '#FFFFFF', // 卡片底 skeleton: '#F2F3F5', // 骨架块 skeletonHi: '#E8EAEE', // 骨架高亮扫过 scrim: 'rgba(15, 23, 42, 0.45)', // 封面图遮罩(对比度保障) }, radius: { sm: 8, md: 16, lg: 24, pill: 999, }, /** 字阶(px, 输出时 ×2 转 rpx) */ fontSize: { display: 40, // 入口主标题 title: 32, // 分组标题/名片姓名 name: 30, meta: 24, // 公司/职位/字段值 caption: 22, // 辅助说明 }, /** 间距(px) */ space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, }, /** 阴影 */ shadow: { card: '0 4rpx 16rpx rgba(15, 23, 42, 0.06)', raised: '0 8rpx 24rpx rgba(15, 23, 42, 0.10)', }, /** 动效时长(ms) */ duration: { fast: 150, normal: 240, slow: 320, }, /** 最小触达尺寸(rpx, 小程序可点性下限 44px≈88rpx, 芯片取 72rpx 高度) */ touch: { chipHeight: 72, btnHeight: 80, avatar: 104, }, }; /** px → rpx(组件配置字段均为 px 口径) */ export function px2rpx(v) { const n = Number(v); return isNaN(n) ? 0 : Math.round(n * 2); } /** * 显隐开关解析: 兼容 true/false、1/0、"1"/"0"、"true"/"false" * 未下发(undefined)时返回 fallback(由调用方决定默认值) */ export function flagOn(v, fallback) { if (v === undefined || v === null || v === '') return fallback; if (v === true || v === 1 || v === '1' || v === 'true') return true; if (v === false || v === 0 || v === '0' || v === 'false') return false; return !!v; } /** 姓名首字兜底(头像无图时) */ export function initialOf(name) { const s = String(name || '').trim(); return s ? s.charAt(0) : '名'; } /** 由字符串确定性派生色相(同名同色, 列表视觉稳定) */ export function hueOf(str) { const s = String(str || ''); let h = 0; for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) % 360; } return h; } /** 头像兜底底色: 主题色优先, 否则按名片确定性色相 */ export function avatarBg(card) { const theme = card && card.theme_color; if (theme) return theme; const h = hueOf((card && (card.card_id || card.name)) || 'card'); return 'hsl(' + h + ', 62%, 55%)'; } /** 主题渐变(头部饰条/主按钮): 有渐变末色用双色, 否则单色 */ export function themeGradient(card, angle) { const theme = (card && card.theme_color) || CardTokens.color.primary; const end = card && card.gradient_end; const deg = (card && card.gradient_angle) || angle || 135; return end ? 'linear-gradient(' + deg + 'deg, ' + theme + ' 0%, ' + end + ' 100%)' : 'linear-gradient(' + deg + 'deg, ' + theme + ' 20%, ' + theme + ' 100%)'; } /** 颜色压暗(ratio 0~1): 用于「渐变圆角」在名片仅单主题色时生成可见明暗渐变终止色。 * 纯函数, 非法输入原样返回(不影响既有主题色渲染) */ export function darkenColor(hex, ratio) { const r = ratio < 0 ? 0 : (ratio > 1 ? 1 : ratio); const h = String(hex || '').replace('#', ''); if (!/^[0-9a-fA-F]{6}$/.test(h)) return hex; const n = parseInt(h, 16); const cr = Math.max(0, Math.round(((n >> 16) & 0xff) * (1 - r))); const cg = Math.max(0, Math.round(((n >> 8) & 0xff) * (1 - r))); const cb = Math.max(0, Math.round((n & 0xff) * (1 - r))); return '#' + ((1 << 24) + (cr << 16) + (cg << 8) + cb).toString(16).slice(1); } /** 安全数字(配置字段) */ export function num(v, dft) { const n = Number(v); return isNaN(n) ? dft : n; } /** 字段行: 过滤空值, 限制条数(其余折叠) */ export function visibleRows(rows, max) { const list = Array.isArray(rows) ? rows.filter(r => r && String(r.value || '').trim() !== '') : []; if (max && max > 0 && list.length > max) { return { shown: list.slice(0, max), rest: list.slice(max), hasMore: true }; } return { shown: list, rest: [], hasMore: false }; } /** * 资料行图标(参考图: 电话/微信/地址带头像, 邮箱/网站/自定义无字形→退化为文字标签) * 口径与 pages_tool/card/detail.vue 的 fieldRows 映射一致(同 glyph 同规则) */ const ROW_ICONS = { mobile: 'icon-dianhua', phone: 'icon-dianhua', wechat: 'icon-weixin', address: 'icon-dizhi', // 方案1(2026-10-22): 邮箱图标 —— 需同时在 iconfont 字库补充信封字形 `icon-youxiang` // (见 common/css/iconfont.css), 字库更新前该行会渲染空白, 故上线前必须配套更新字库。 // field 键名多写几套兜底(后端可能用 email / mail / 中文「邮箱」), 命中其一即可 email: 'icon-youxiang', mail: 'icon-youxiang', '邮箱': 'icon-youxiang', // 网站/链接行(2026-10-22): 字库已有链条字形 icon-fuzhilianjie(\e03f), 直接复用 website: 'icon-fuzhilianjie', link: 'icon-fuzhilianjie', url: 'icon-fuzhilianjie', '网站': 'icon-fuzhilianjie', }; 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 调起 / 其它端复制引导) * http(s) → web(跳转网站, 经 web-view 承载页) 空 → 无动作(纯展示, 如地址/自定义) * 返回 null 表示不可点; type 取值与父组件 doAct 的 tel/mail/web/copy 分支对齐 */ export function rowActOf(row) { const href = String((row && row.href) || '').trim(); const value = String((row && row.value) || '').trim(); if (!href || !value) return null; if (/^tel:/i.test(href)) return { type: 'tel', value: value }; if (/^mailto:/i.test(href)) return { type: 'mail', value: value }; if (/^https?:\/\//i.test(href)) return { type: 'web', value: value }; return { type: 'copy', value: value }; } /** 二维码位置(四档): rows 资料行下方(默认) / head 头部右侧 / corner 右上角浮层 / bottom 主按钮上方 */ export const QR_POSITIONS = ['rows', 'head', 'corner', 'bottom']; /** 位置档位中文别名(面板下拉若直接存中文档名, 归一化时兼容) */ const QR_POSITION_ALIAS = { '资料行下方': 'rows', '头部右侧': 'head', '右上角浮层': 'corner', '主按钮上方': 'bottom', }; export function normalizeQrPosition(v) { const s = String(v || '').trim(); if (QR_POSITIONS.indexOf(s) >= 0) return s; return QR_POSITION_ALIAS[s] || 'rows'; } /** 各档位的二维码边长(rpx): 头部右侧紧凑, 浮层带衬底, 行下/底部大图便于扫 */ /** 二维码默认尺寸(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 }; /** * 二维码外观样式(四档): plain 原图 / card 白卡描边(默认) / theme 主题色底 / * gradient 渐变描边(码芯白底衬底, 保可扫) * 多彩体现在装饰层(主题色/渐变), 码本身保持黑白 —— 可扫性优先 */ export const QR_STYLES = ['plain', 'card', 'theme', 'gradient']; /** 外观档位中文别名(面板下拉若直接存中文档名, 归一化时兼容) */ const QR_STYLE_ALIAS = { '原图': 'plain', '白卡描边': 'card', '主题色衬底': 'theme', '渐变圆角': 'gradient', }; export function normalizeQrStyle(v) { const s = String(v || '').trim(); if (QR_STYLES.indexOf(s) >= 0) return s; return QR_STYLE_ALIAS[s] || 'card'; } /** 二维码边长: 自定义 > 0 时用自定义(px), 否则按档位自动(rpx) */ export function resolveQrSize(position, custom) { const n = Number(custom); if (!isNaN(n) && n > 0) return n * 2; return QR_SIZES[position] || QR_SIZES.rows; } /** 微调位移: 任一轴非零才输出 transform(WXSS 支持 translate; 不支持 calc) */ export function qrTransform(offsetX, offsetY) { const x = Number(offsetX) || 0; const y = Number(offsetY) || 0; 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 兜底; * 2.19.3 扩展: sms 短信/map 导航/link 页面链接(系统链接对话框)) */ export const ACT_ACTION_TYPES = ['tel', 'wx', 'mail', 'web', 'copy', 'sms', 'map', 'link']; 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.2): actDeleted = { type: 0|1 }, 1=已删除(C端不渲染, 面板行隐藏, * 经「恢复」chips 找回)。与 actVis(眼睛临时隐藏)独立 —— 删除与隐藏 C 端均不渲染。 * 缺键/非法 = 未删除(0), 历史配置无此字段 → 零变化 */ export function normalizeActDeleted(deleted) { const src = deleted && typeof deleted === 'object' ? deleted : {}; const out = {}; ACT_TYPES.forEach(t => { out[t] = String(src[t]) === '1' ? 1 : 0; }); 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')) + '")'; }