feat(mpuniapp): 实现一键保存至手机通讯录与 RFC 6350 vCard 导出功能
这个提交包含在:
@@ -51,6 +51,14 @@ export interface CardProfile {
|
||||
status: CardStatus;
|
||||
/** 主题样式代码 (如 'corporate_navy', 'tech_dark', 'gold_luxe') */
|
||||
themeCode?: string;
|
||||
/** 微官网 / 个人网页 URL */
|
||||
url?: string;
|
||||
/** 街道地址 */
|
||||
address?: string;
|
||||
/** 城市 */
|
||||
city?: string;
|
||||
/** 国家 */
|
||||
country?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,14 @@ export interface CardProfile {
|
||||
status: CardStatus;
|
||||
/** 主题样式代码 (如 'corporate_navy', 'tech_dark', 'gold_luxe') */
|
||||
themeCode?: string;
|
||||
/** 微官网 / 个人网页 URL */
|
||||
url?: string;
|
||||
/** 街道地址 */
|
||||
address?: string;
|
||||
/** 城市 */
|
||||
city?: string;
|
||||
/** 国家 */
|
||||
country?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 用户端 mpuniapp 一键存入手机通讯录 (vCard 动态导出与原生导入) 单元测试
|
||||
* 严格按照 AGENTS.md 3.3、4.3、4.4 规范与 PRD 3.1 节编写 (TDD Red -> Green)
|
||||
*/
|
||||
|
||||
import { CardProfile } from '../../src/comm/types/omni';
|
||||
|
||||
describe('OmniCard AI 用户端 vCard 导出与通讯录集成 (vcard-export.uts)', () => {
|
||||
let toastCalls: Array<{ title: string; icon?: string }>;
|
||||
let addPhoneContactCalls: Array<any>;
|
||||
let mockAddPhoneContactResult: { success: boolean; errMsg?: string };
|
||||
|
||||
beforeEach(() => {
|
||||
toastCalls = [];
|
||||
addPhoneContactCalls = [];
|
||||
mockAddPhoneContactResult = { success: true };
|
||||
|
||||
// 全局 uni 运行时 Mock
|
||||
(global as any).uni = {
|
||||
showToast: (options: { title: string; icon?: string }) => {
|
||||
toastCalls.push(options);
|
||||
},
|
||||
addPhoneContact: (options: any) => {
|
||||
addPhoneContactCalls.push(options);
|
||||
if (mockAddPhoneContactResult.success) {
|
||||
options.success?.({ errMsg: 'addPhoneContact:ok' });
|
||||
} else {
|
||||
options.fail?.({ errMsg: mockAddPhoneContactResult.errMsg || 'addPhoneContact:fail cancel' });
|
||||
}
|
||||
options.complete?.();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (global as any).uni;
|
||||
delete (global as any).window;
|
||||
delete (global as any).Blob;
|
||||
});
|
||||
|
||||
const fullCard: CardProfile = {
|
||||
id: 'card_1001',
|
||||
tenantId: 'tenant_001',
|
||||
userId: 'user_001',
|
||||
name: 'Alex Morgan',
|
||||
title: 'Chief Technology Officer',
|
||||
company: 'OmniCard AI Global Inc.',
|
||||
slogan: 'Empowering global trade with AI and NFC technology.',
|
||||
avatarUrl: 'https://cdn.omnicard.ai/avatars/alex.jpg',
|
||||
phone: '+86-13800138000',
|
||||
email: 'alex.morgan@omnicard.ai',
|
||||
whatsapp: '+8613800138000',
|
||||
linkedin: 'https://www.linkedin.com/in/alex-morgan',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'published',
|
||||
themeCode: 'corporate_navy',
|
||||
url: 'https://omnicard.ai/u/alex-morgan',
|
||||
address: '100 Innovation Boulevard',
|
||||
city: 'Shenzhen',
|
||||
country: 'China',
|
||||
};
|
||||
|
||||
describe('1. 辅助方法与 RFC 6350 规范支持', () => {
|
||||
it('1.1 splitName 应能智能拆分中英文姓名 (firstName 与 lastName)', () => {
|
||||
// @ts-ignore
|
||||
const { splitName } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
// 英文单空格
|
||||
expect(splitName('Alex Morgan')).toEqual({ firstName: 'Alex', lastName: 'Morgan' });
|
||||
// 英文多空格
|
||||
expect(splitName('Dr. Alex J. Morgan')).toEqual({ firstName: 'Dr. Alex J.', lastName: 'Morgan' });
|
||||
// 中文 2 字
|
||||
expect(splitName('张三')).toEqual({ firstName: '三', lastName: '张' });
|
||||
// 中文 3 字
|
||||
expect(splitName('张小龙')).toEqual({ firstName: '小龙', lastName: '张' });
|
||||
// 中文常见复姓 4 字
|
||||
expect(splitName('欧阳六分')).toEqual({ firstName: '六分', lastName: '欧阳' });
|
||||
expect(splitName('诸葛孔明')).toEqual({ firstName: '孔明', lastName: '诸葛' });
|
||||
// 单字
|
||||
expect(splitName('李')).toEqual({ firstName: '李', lastName: '' });
|
||||
// 空字符
|
||||
expect(splitName('')).toEqual({ firstName: '名片联系人', lastName: '' });
|
||||
});
|
||||
|
||||
it('1.2 escapeVCardText 应正确转义特殊字符 (反斜杠, 分号, 逗号, 换行符)', () => {
|
||||
// @ts-ignore
|
||||
const { escapeVCardText } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
const raw = 'Special\\Name; with, comma\nand newline\r\nand CRLF';
|
||||
const escaped = escapeVCardText(raw);
|
||||
|
||||
expect(escaped).toContain('\\\\');
|
||||
expect(escaped).toContain('\\;');
|
||||
expect(escaped).toContain('\\,');
|
||||
expect(escaped).toContain('\\n');
|
||||
expect(escaped).not.toContain('\r');
|
||||
});
|
||||
|
||||
it('1.3 foldVCardLine 应严格遵守 75 字符行折叠规范', () => {
|
||||
// @ts-ignore
|
||||
const { foldVCardLine } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
const shortLine = 'FN:Alex Morgan';
|
||||
expect(foldVCardLine(shortLine)).toBe(shortLine);
|
||||
|
||||
const longLine = 'NOTE:This is an extremely long note designed to verify RFC 6350 line folding mechanism with more than seventy-five characters.';
|
||||
const folded = foldVCardLine(longLine, 75);
|
||||
const lines = folded.split('\r\n');
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
expect(lines[0].length).toBeLessThanOrEqual(75);
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
expect(lines[i].startsWith(' ')).toBe(true);
|
||||
expect(lines[i].length).toBeLessThanOrEqual(75);
|
||||
}
|
||||
});
|
||||
|
||||
it('1.4 buildVCardString 应生成完备合规的 RFC 6350 / RFC 2426 vCard 文本', () => {
|
||||
// @ts-ignore
|
||||
const { buildVCardString } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
const vcard = buildVCardString(fullCard);
|
||||
|
||||
// 基本结构
|
||||
expect(vcard.startsWith('BEGIN:VCARD\r\n')).toBe(true);
|
||||
expect(vcard.endsWith('END:VCARD\r\n')).toBe(true);
|
||||
expect(vcard).toContain('VERSION:3.0\r\n');
|
||||
|
||||
// 核心字段
|
||||
expect(vcard).toContain('FN:Alex Morgan\r\n');
|
||||
expect(vcard).toContain('N:Morgan;Alex;;;\r\n');
|
||||
expect(vcard).toContain('ORG:OmniCard AI Global Inc.\r\n');
|
||||
expect(vcard).toContain('TITLE:Chief Technology Officer\r\n');
|
||||
expect(vcard).toContain('TEL;TYPE=CELL:+86-13800138000\r\n');
|
||||
expect(vcard).toContain('EMAIL;TYPE=WORK:alex.morgan@omnicard.ai\r\n');
|
||||
expect(vcard).toContain('URL:https://omnicard.ai/u/alex-morgan\r\n');
|
||||
expect(vcard).toContain('NOTE:Empowering global trade with AI and NFC technology.\r\n');
|
||||
expect(vcard).toContain('X-SOCIALPROFILE;TYPE=whatsapp:+8613800138000\r\n');
|
||||
expect(vcard).toContain('X-SOCIALPROFILE;TYPE=linkedin:https://www.linkedin.com/in/alex-morgan\r\n');
|
||||
expect(vcard).toContain('PHOTO;VALUE=URI:https://cdn.omnicard.ai/avatars/alex.jpg\r\n');
|
||||
expect(vcard).toContain('ADR;TYPE=WORK:;;100 Innovation Boulevard;Shenzhen;;;China\r\n');
|
||||
});
|
||||
|
||||
it('1.5 buildVCardString 对极简名片与空字段应优雅防御', () => {
|
||||
// @ts-ignore
|
||||
const { buildVCardString } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
const minimalCard: CardProfile = {
|
||||
name: 'Simple User',
|
||||
} as any;
|
||||
|
||||
const vcard = buildVCardString(minimalCard);
|
||||
expect(vcard).toContain('BEGIN:VCARD');
|
||||
expect(vcard).toContain('FN:Simple User');
|
||||
expect(vcard).not.toContain('TEL;');
|
||||
expect(vcard).not.toContain('EMAIL;');
|
||||
expect(vcard).not.toContain('ORG;');
|
||||
expect(vcard).toContain('END:VCARD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. App 原生端通讯录写入能力 (uni.addPhoneContact)', () => {
|
||||
it('2.1 saveToContacts 应正确映射字段并调用 uni.addPhoneContact', async () => {
|
||||
// @ts-ignore
|
||||
const { saveToContacts } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
const result = await saveToContacts(fullCard);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(addPhoneContactCalls.length).toBe(1);
|
||||
|
||||
const params = addPhoneContactCalls[0];
|
||||
expect(params.firstName).toBe('Alex');
|
||||
expect(params.lastName).toBe('Morgan');
|
||||
expect(params.organization).toBe('OmniCard AI Global Inc.');
|
||||
expect(params.title).toBe('Chief Technology Officer');
|
||||
expect(params.mobilePhoneNumber).toBe('+86-13800138000');
|
||||
expect(params.email).toBe('alex.morgan@omnicard.ai');
|
||||
expect(params.remark).toBe('Empowering global trade with AI and NFC technology.');
|
||||
expect(params.url).toBe('https://omnicard.ai/u/alex-morgan');
|
||||
expect(params.workAddressStreet).toBe('100 Innovation Boulevard');
|
||||
expect(params.workAddressCity).toBe('Shenzhen');
|
||||
expect(params.workAddressCountry).toBe('China');
|
||||
|
||||
// 验证成功 Toast
|
||||
expect(toastCalls.length).toBe(1);
|
||||
expect(toastCalls[0].title).toBe('已存入通讯录');
|
||||
});
|
||||
|
||||
it('2.2 当 uni.addPhoneContact 失败或取消时友好兜底且不崩溃', async () => {
|
||||
// @ts-ignore
|
||||
const { saveToContacts } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
mockAddPhoneContactResult = {
|
||||
success: false,
|
||||
errMsg: 'addPhoneContact:fail cancel',
|
||||
};
|
||||
|
||||
const result = await saveToContacts(fullCard);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(addPhoneContactCalls.length).toBe(1);
|
||||
expect(toastCalls.length).toBe(1);
|
||||
expect(toastCalls[0].title).toBe('未存入通讯录');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. H5/Web 端降级与 vCard (.vcf) 自动下载', () => {
|
||||
it('3.1 在 Web/H5 环境无原生 API 时,自动生成 .vcf Blob 并触发下载', async () => {
|
||||
// 移除 uni.addPhoneContact 模拟纯 H5 环境
|
||||
delete (global as any).uni.addPhoneContact;
|
||||
|
||||
let createdBlob: any = null;
|
||||
let clicked = false;
|
||||
let downloadAttr = '';
|
||||
let hrefAttr = '';
|
||||
let revokedUrl = '';
|
||||
|
||||
// 模拟全局 Blob 与 window.document
|
||||
(global as any).Blob = class MockBlob {
|
||||
content: any[];
|
||||
options: any;
|
||||
constructor(content: any[], options: any) {
|
||||
this.content = content;
|
||||
this.options = options;
|
||||
createdBlob = this;
|
||||
}
|
||||
};
|
||||
|
||||
(global as any).window = {
|
||||
URL: {
|
||||
createObjectURL: jest.fn((blob) => 'blob:mock-url-123'),
|
||||
revokeObjectURL: jest.fn((url) => {
|
||||
revokedUrl = url;
|
||||
}),
|
||||
},
|
||||
document: {
|
||||
createElement: jest.fn((tag) => {
|
||||
if (tag === 'a') {
|
||||
return {
|
||||
style: {},
|
||||
setAttribute: (name: string, val: string) => {
|
||||
if (name === 'download') downloadAttr = val;
|
||||
},
|
||||
set href(val: string) {
|
||||
hrefAttr = val;
|
||||
},
|
||||
get href() {
|
||||
return hrefAttr;
|
||||
},
|
||||
click: () => {
|
||||
clicked = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
body: {
|
||||
appendChild: jest.fn(),
|
||||
removeChild: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const { saveToContacts } = require('../../../mpuniapp/common/vcard-export.uts');
|
||||
|
||||
const result = await saveToContacts(fullCard);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(createdBlob).not.toBeNull();
|
||||
expect(createdBlob.options.type).toContain('text/vcard');
|
||||
expect(downloadAttr).toBe('Alex Morgan.vcf');
|
||||
expect(hrefAttr).toBe('blob:mock-url-123');
|
||||
expect(clicked).toBe(true);
|
||||
|
||||
// 成功 Toast 提示
|
||||
expect(toastCalls.length).toBe(1);
|
||||
expect(toastCalls[0].title).toBe('已存入通讯录');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,30 @@
|
||||
|
||||
export type CardStatus = 'draft' | 'published' | 'archived';
|
||||
|
||||
export interface CardProfile {
|
||||
id?: string | number;
|
||||
tenantId?: string | number;
|
||||
userId?: string | number;
|
||||
name: string;
|
||||
title?: string;
|
||||
company?: string;
|
||||
slogan?: string;
|
||||
avatarUrl?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
whatsapp?: string;
|
||||
linkedin?: string;
|
||||
timezone?: string;
|
||||
audioPronunciationUrl?: string;
|
||||
status?: CardStatus;
|
||||
themeCode?: string;
|
||||
url?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type BentoBlockType = 'hero' | 'case' | 'article' | 'social' | 'video' | 'form';
|
||||
|
||||
export type BentoColSpan = 1 | 2;
|
||||
|
||||
267
mpuniapp/common/vcard-export.uts
普通文件
267
mpuniapp/common/vcard-export.uts
普通文件
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户