268 行
7.6 KiB
Plaintext
268 行
7.6 KiB
Plaintext
/**
|
|
* 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<boolean> {
|
|
return new Promise<boolean>((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<boolean> {
|
|
return new Promise<boolean>((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<boolean> {
|
|
// #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);
|
|
}
|