From c07846a26302264401ad6f7a9fae64c564a89944 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Sun, 20 Sep 2026 17:36:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(mpuniapp):=20=E5=AE=9E=E7=8E=B0=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E4=BF=9D=E5=AD=98=E8=87=B3=E6=89=8B=E6=9C=BA=E9=80=9A?= =?UTF-8?q?=E8=AE=AF=E5=BD=95=E4=B8=8E=20RFC=206350=20vCard=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/types/omni.ts | 8 + backend/src/comm/types/omni.ts | 8 + backend/test/mpuniapp/vcard-export.test.ts | 282 +++++++++++++++++++++ mpuniapp/common/types.uts | 24 ++ mpuniapp/common/vcard-export.uts | 267 +++++++++++++++++++ 5 files changed, 589 insertions(+) create mode 100644 backend/test/mpuniapp/vcard-export.test.ts create mode 100644 mpuniapp/common/vcard-export.uts diff --git a/admin/src/types/omni.ts b/admin/src/types/omni.ts index 02e9679..9c7d77e 100644 --- a/admin/src/types/omni.ts +++ b/admin/src/types/omni.ts @@ -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; } /** diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts index 39f9b68..f4b5c6d 100644 --- a/backend/src/comm/types/omni.ts +++ b/backend/src/comm/types/omni.ts @@ -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; } /** diff --git a/backend/test/mpuniapp/vcard-export.test.ts b/backend/test/mpuniapp/vcard-export.test.ts new file mode 100644 index 0000000..a209049 --- /dev/null +++ b/backend/test/mpuniapp/vcard-export.test.ts @@ -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; + 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('已存入通讯录'); + }); + }); +}); diff --git a/mpuniapp/common/types.uts b/mpuniapp/common/types.uts index f390aad..52713bc 100644 --- a/mpuniapp/common/types.uts +++ b/mpuniapp/common/types.uts @@ -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; diff --git a/mpuniapp/common/vcard-export.uts b/mpuniapp/common/vcard-export.uts new file mode 100644 index 0000000..e393de8 --- /dev/null +++ b/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 { + 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); +}