/** * OmniCard AI (CRMmp) - 用户端一键保存至手机通讯录 (vCard 动态导出与原生导入) * 严格遵循 AGENTS.md 3.3、4.3、4.4 规范与 RFC 6350 / RFC 2426 国际通用标准 * 一套代码兼顾 App 原生通讯录写入与 H5/Web 端 .vcf 文件动态导出 */ import { CardProfile } from './types.uts'; /** * 智能拆分中英文姓名 (firstName 与 lastName) */ export function splitName(fullName: string): { firstName: string; lastName: string } { if (!fullName || !fullName.trim()) { return { firstName: '名片联系人', lastName: '' }; } const trimmed = fullName.trim(); // 1. 若包含空格,按空格分割 (英文名或带前缀的名) if (trimmed.includes(' ')) { const parts = trimmed.split(/\s+/); const lastName = parts.pop() || ''; const firstName = parts.join(' '); return { firstName, lastName }; } // 2. 中文常见复姓列表 const doubleSurnames = [ '欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人', '夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '淳于', '单于', '太叔', '申屠', '公孙', '仲孙', '轩辕', '令狐', '钟离', '宇文', '长孙', '慕容', '司徒', '司空' ]; if (trimmed.length > 2) { const prefix2 = trimmed.slice(0, 2); if (doubleSurnames.indexOf(prefix2) !== -1) { return { lastName: prefix2, firstName: trimmed.slice(2), }; } } // 3. 常见 2 字及以上单姓中文名 if (trimmed.length >= 2) { return { lastName: trimmed.slice(0, 1), firstName: trimmed.slice(1), }; } // 4. 单字名 return { firstName: trimmed, lastName: '', }; } /** * RFC 6350 字段值特殊字符转义 */ export function escapeVCardText(text: string): string { if (!text) return ''; return text .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\r\n|\r|\n/g, '\\n'); } /** * 严格执行 RFC 6350 第 3.2 节长行折叠 (Line Folding) 规范 (每行最大 75 字符) */ export function foldVCardLine(line: string, maxLen: number = 75): string { if (line.length <= maxLen) return line; let result = line.slice(0, maxLen); let remaining = line.slice(maxLen); while (remaining.length > 0) { result += '\r\n ' + remaining.slice(0, maxLen - 1); remaining = remaining.slice(maxLen - 1); } return result; } /** * 生成符合 RFC 6350 / RFC 2426 规范的 vCard 字符串 (默认 3.0,国际通用设备兼容性最高) */ export function buildVCardString(card: CardProfile, version: string = '3.0'): string { const lines: string[] = []; // 1. 开始标记与版本 lines.push('BEGIN:VCARD'); lines.push(`VERSION:${version}`); // 2. 姓名 (FN & N) const name = card.name ? card.name.trim() : '名片联系人'; lines.push(`FN:${escapeVCardText(name)}`); const { firstName, lastName } = splitName(name); lines.push(`N:${escapeVCardText(lastName)};${escapeVCardText(firstName)};;;`); // 3. 组织与头衔 if (card.company) { lines.push(`ORG:${escapeVCardText(card.company)}`); } if (card.title) { lines.push(`TITLE:${escapeVCardText(card.title)}`); } // 4. 电话与电子邮箱 if (card.phone) { lines.push(`TEL;TYPE=CELL:${card.phone}`); } if (card.email) { lines.push(`EMAIL;TYPE=WORK:${card.email}`); } // 5. 微官网 URL 与 Slogan 备注 if (card.url) { lines.push(`URL:${card.url}`); } if (card.slogan) { lines.push(`NOTE:${escapeVCardText(card.slogan)}`); } // 6. 社交主页链接 (WhatsApp / LinkedIn) if (card.whatsapp) { lines.push(`X-SOCIALPROFILE;TYPE=whatsapp:${card.whatsapp}`); } if (card.linkedin) { lines.push(`X-SOCIALPROFILE;TYPE=linkedin:${card.linkedin}`); } // 7. 头像照片 URL if (card.avatarUrl) { if (version === '3.0') { lines.push(`PHOTO;VALUE=URI:${card.avatarUrl}`); } else { lines.push(`PHOTO:${card.avatarUrl}`); } } // 8. 工作地址 (Street, Locality/City, Country) if (card.address || card.city || card.country) { const street = escapeVCardText(card.address || ''); const city = escapeVCardText(card.city || ''); const country = escapeVCardText(card.country || ''); lines.push(`ADR;TYPE=WORK:;;${street};${city};;;${country}`); } // 9. 结束标记 lines.push('END:VCARD'); return lines.map((l) => foldVCardLine(l)).join('\r\n') + '\r\n'; } /** * App 原生调用 uni.addPhoneContact 保存至系统通讯录 */ function saveToAppContacts(card: CardProfile): Promise { return new Promise((resolve) => { const { firstName, lastName } = splitName(card.name); uni.addPhoneContact({ firstName: firstName || '名片联系人', lastName: lastName || '', organization: card.company || '', title: card.title || '', mobilePhoneNumber: card.phone || '', email: card.email || '', remark: card.slogan || '', url: card.url || '', workAddressStreet: card.address || '', workAddressCity: card.city || '', workAddressCountry: card.country || '', success: () => { uni.showToast({ title: '已存入通讯录', icon: 'success', }); resolve(true); }, fail: (err: any) => { console.warn('saveToContacts fail:', err); uni.showToast({ title: '未存入通讯录', icon: 'none', }); resolve(false); }, }); }); } /** * H5 / Web 端生成 .vcf Blob 并触发自动下载或拉起系统通讯录 */ function exportH5VCard(card: CardProfile): Promise { return new Promise((resolve) => { try { const vcfContent = buildVCardString(card, '3.0'); const blob = new Blob([vcfContent], { type: 'text/vcard;charset=utf-8;' }); const fileName = `${card.name ? card.name.trim() : 'contact'}.vcf`; if (typeof window !== 'undefined' && window.document) { const url = window.URL.createObjectURL(blob); const link = window.document.createElement('a'); link.href = url; link.setAttribute('download', fileName); link.style.display = 'none'; window.document.body.appendChild(link); link.click(); setTimeout(() => { try { window.document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (e) {} }, 200); if (typeof uni !== 'undefined' && typeof uni.showToast === 'function') { uni.showToast({ title: '已存入通讯录', icon: 'success', }); } resolve(true); } else { resolve(false); } } catch (err) { console.error('exportH5VCard error:', err); if (typeof uni !== 'undefined' && typeof uni.showToast === 'function') { uni.showToast({ title: '名片导出失败', icon: 'none', }); } resolve(false); } }); } /** * 一键保存名片至手机通讯录 (核心统一对外 API) * * @param card 名片基础资料契约 * @returns 是否成功存入/唤起下载 */ export function saveToContacts(card: CardProfile): Promise { // #ifdef APP if (typeof uni !== 'undefined' && typeof (uni as any).addPhoneContact === 'function') { return saveToAppContacts(card); } // #endif // #ifdef H5 return exportH5VCard(card); // #endif // 跨平台降级与非 HBuilderX 预编译测试环境嗅探 if (typeof uni !== 'undefined' && typeof (uni as any).addPhoneContact === 'function') { return saveToAppContacts(card); } return exportH5VCard(card); }