/** * 名片 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 || '')] || ''; } /** * 资料行点击动作: 由服务端下发的 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)'; }