/**
* 名片 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%)';
}
/** 安全数字(配置字段) */
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',
};
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) → 复制链接 空 → 无动作(纯展示, 如地址/自定义)
* 返回 null 表示不可点; type 取值与父组件 doAct 的 tel/mail/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 };
return { type: 'copy', value: value };
}
/** 二维码位置(四档): rows 资料行下方(默认) / head 头部右侧 / corner 右上角浮层 / bottom 主按钮上方 */
export const QR_POSITIONS = ['rows', 'head', 'corner', 'bottom'];
export function normalizeQrPosition(v) {
const s = String(v || '').trim();
return QR_POSITIONS.indexOf(s) >= 0 ? s : 'rows';
}
/** 各档位的二维码边长(rpx): 头部右侧紧凑, 浮层带衬底, 行下/底部大图便于扫 */
export const QR_SIZES = { rows: 180, head: 120, corner: 120, bottom: 180 };
/**
* 二维码外观样式(四档): plain 原图 / card 白卡描边(默认) / theme 主题色底 /
* gradient 渐变描边(码芯白底衬底, 保可扫)
* 多彩体现在装饰层(主题色/渐变), 码本身保持黑白 —— 可扫性优先
*/
export const QR_STYLES = ['plain', 'card', 'theme', 'gradient'];
export function normalizeQrStyle(v) {
const s = String(v || '').trim();
return QR_STYLES.indexOf(s) >= 0 ? 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 = '';
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.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')) + '")';
}