239 lines
9.0 KiB
JavaScript
239 lines
9.0 KiB
JavaScript
/**
|
||
* 名片 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)';
|
||
}
|