/** * OmniCard AI (CRMmp) - 移动端拍照上传纸质名片 OCR 识别入库业务驱动层 (Point 25) * 严格遵循 uts 强类型规范、零 emoji 与 AGENTS.md 约束 */ import { ExchangeForm } from './useExchange.uts'; export type ExtractedContactInfo = { name: string; company: string; phone: string; email: string; title: string; notes: string; rawText: string; }; export type OcrScanState = { scanning: boolean; rawText: string; imagePath: string; lastExtracted: ExtractedContactInfo | null; errorMessage: string; }; export type HandleOcrUploadOptions = { mockText?: string; sourceType?: string[]; overwrite?: boolean; }; /** * 规范化国际 E.164 电话号码 * 例如: * - "+86 138 0013 8000" -> "+8613800138000" * - "+1 (555) 234-5678" -> "+15552345678" * - "0086 13800138000" -> "+8613800138000" * - "13912345678" -> "+8613912345678" */ export function normalizeE164Phone(rawPhone: string): string { const trimmed = rawPhone.trim(); if (trimmed === '') { return ''; } // 替换开头的 00 国际前缀为 + let cleaned = trimmed.replace(/^00/, '+'); // 若开头有 + if (cleaned.startsWith('+')) { const digitsOnly = cleaned.substring(1).replace(/[\s\-\(\)\.]/g, ''); if (/^\d{7,15}$/.test(digitsOnly)) { return '+' + digitsOnly; } } // 移除非数字符号 const digitsOnly = cleaned.replace(/[\s\-\(\)\.]/g, ''); // 中国国内 11 位手机号码自动补充 +86 if (/^1[3-9]\d{9}$/.test(digitsOnly)) { return '+86' + digitsOnly; } // 已经带有国家代码的无+纯数字 if (/^\d{10,15}$/.test(digitsOnly)) { return '+' + digitsOnly; } return cleaned; } /** * 创建 OCR 扫描处理状态 */ export function createOcrScanState(): OcrScanState { return { scanning: false, rawText: '', imagePath: '', lastExtracted: null, errorMessage: '', }; } /** * 预览已拍照的名片图片 */ export function previewOcrImage(ocrState: OcrScanState): void { if (ocrState.imagePath !== '') { // #ifndef UNI-APP-X-TEST try { if (typeof uni !== 'undefined' && typeof uni.previewImage === 'function') { uni.previewImage({ urls: [ocrState.imagePath], current: ocrState.imagePath, }); } } catch (_e) {} // #endif } } /** * 删除已拍摄的名片图片 */ export function removeOcrImage(ocrState: OcrScanState): void { ocrState.imagePath = ''; } /** * 判断是否为职位关键词 */ function isTitleCandidate(text: string): boolean { const lower = text.toLowerCase(); const englishTitles = [ 'ceo', 'cto', 'coo', 'cfo', 'cio', 'cmo', 'vp', 'vice president', 'director', 'manager', 'engineer', 'architect', 'founder', 'co-founder', 'president', 'partner', 'consultant', 'lead', 'officer', 'specialist', ]; for (let i = 0; i < englishTitles.length; i++) { const title = englishTitles[i]; if (lower === title || lower.startsWith(title + ' ') || lower.endsWith(' ' + title)) { return true; } if (new RegExp('\\b' + title + '\\b', 'i').test(text)) { return true; } } const chineseTitles = [ '总裁', '总经理', '总监', '经理', '主管', '创始人', '联合创始人', '合伙人', '工程师', '架构师', '顾问', '助理', '教授', '博士', '设计师', ]; for (let i = 0; i < chineseTitles.length; i++) { if (text.includes(chineseTitles[i])) { return true; } } return false; } /** * 判断是否为公司/组织关键词 */ function isCompanyCandidate(text: string): boolean { const englishCompanyKeywords = [ 'inc', 'ltd', 'llc', 'corp', 'corporation', 'co', 'company', 'group', 'holdings', 'enterprises', 'solutions', 'technologies', 'systems', 'gmbh', 'pte', ]; for (let i = 0; i < englishCompanyKeywords.length; i++) { const kw = englishCompanyKeywords[i]; if (new RegExp('\\b' + kw + '\\b', 'i').test(text)) { return true; } } const chineseCompanyKeywords = [ '公司', '集团', '有限', '股份', '科技', '网络', '事务所', '工作室', '企业', '中心', ]; for (let i = 0; i < chineseCompanyKeywords.length; i++) { if (text.includes(chineseCompanyKeywords[i])) { return true; } } return false; } /** * 判断是否属于地址、网址或标语等干扰噪音 */ function isNoiseLine(text: string): boolean { // 网址 if (/^https?:\/\//i.test(text) || /^www\./i.test(text) || /\.(com|org|net|io|cn|ai|cc|me)(\/|$)/i.test(text)) { return true; } // 地址 if (/(?:地址|address|addr)[\s::]/i.test(text)) { return true; } if (/(?:大厦|科技园|街|路|区|市|省|层|楼|室|road|street|avenue|blvd|floor|suite|building)/i.test(text)) { return true; } // 纯标语 (长文本) if (text.length > 25 && /(?:致力于|打造|引领|专注|赋能|empower|leading|innovative)/i.test(text)) { return true; } // 纯特殊符号乱码 if (/^[#!\@\$\%\^\&\*\(\)\_\+\-\=\[\]\{\}\\\|\;\:\'\"\,\<\.\>\/\?\s~`0-9]+$/.test(text) && !/\d{7,}/.test(text)) { return true; } return false; } /** * 核心算法:解析纸质名片 OCR 原始文本,提取结构化名片联系信息 */ export function parseOcrCardText(rawOcrText: string): ExtractedContactInfo { const result: ExtractedContactInfo = { name: '', company: '', phone: '', email: '', title: '', notes: '', rawText: rawOcrText, }; if (!rawOcrText || rawOcrText.trim() === '') { return result; } const rawLines = rawOcrText .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .split('\n'); const lines: string[] = []; for (let i = 0; i < rawLines.length; i++) { const l = rawLines[i].trim(); if (l !== '') { lines.push(l); } } if (lines.length === 0) { return result; } // 1. 优先提取 Email const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/i; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(emailRegex); if (match != null && match.length > 0) { result.email = match[0].trim(); break; } } // 2. 提取电话号码 (支持国际 E.164 与国内常见格式) for (let i = 0; i < lines.length; i++) { const line = lines[i]; // 忽略已经是纯邮箱的行 if (result.email !== '' && line.includes(result.email) && !/(?:tel|phone|mobile|cell|whatsapp|电话|手机)/i.test(line)) { continue; } // 检查带前缀的号码 const prefixedMatch = line.match(/(?:tel|phone|mobile|cell|whatsapp|fax|电话|手机|座机|联系方式)[\s::]*([+0-9\s\-\(\)\.]{6,25})/i); if (prefixedMatch != null && prefixedMatch[1]) { const extractedPhone = normalizeE164Phone(prefixedMatch[1]); if (extractedPhone !== '') { result.phone = extractedPhone; break; } } // 检查国际 + 号号码 const intlMatch = line.match(/(\+[1-9]\d{0,3}[\s\-\(\)]*(?:\d[\s\-\(\)]*){6,14}\d)/); if (intlMatch != null && intlMatch[1]) { const extractedPhone = normalizeE164Phone(intlMatch[1]); if (extractedPhone !== '') { result.phone = extractedPhone; break; } } // 检查中国国内 11 位手机号码 const cnMobileMatch = line.match(/(?:^|[^\d])(1[3-9]\d{9})(?:[^\d]|$)/); if (cnMobileMatch != null && cnMobileMatch[1]) { result.phone = normalizeE164Phone(cnMobileMatch[1]); break; } } // 3. 提取带有明确标签的字段 for (let i = 0; i < lines.length; i++) { const line = lines[i]; // 姓名标签 const nameMatch = line.match(/^(?:姓名|联系人|Name|Contact(?:\s+person)?)[\s::]+(.+)$/i); if (nameMatch != null && nameMatch[1] && result.name === '') { result.name = nameMatch[1].trim(); continue; } // 职务标签 const titleMatch = line.match(/^(?:职务|职位|头衔|Title|Position|Role)[\s::]+(.+)$/i); if (titleMatch != null && titleMatch[1] && result.title === '') { result.title = titleMatch[1].trim(); continue; } // 公司标签 const compMatch = line.match(/^(?:公司|企业|单位|组织|Company|Organization|Org|Enterprise|Firm)[\s::]+(.+)$/i); if (compMatch != null && compMatch[1] && result.company === '') { result.company = compMatch[1].trim(); continue; } // 备注标签 const noteMatch = line.match(/^(?:备注|留言|Note|Notes)[\s::]+(.+)$/i); if (noteMatch != null && noteMatch[1] && result.notes === '') { result.notes = noteMatch[1].trim(); continue; } } // 4. 识别同行包含姓名和职务的模式,例如:"李小峰 / 首席架构师", "Alex Morgan | CTO" if (result.name === '' || result.title === '') { for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes(result.email) || (result.phone !== '' && line.includes(result.phone))) { continue; } const splitMatch = line.match(/^([^\/|(\-]+)\s*[\/|(\-]\s*([^\/|)\-]+)$/); if (splitMatch != null && splitMatch[1] && splitMatch[2]) { const part1 = splitMatch[1].trim(); const part2 = splitMatch[2].trim(); if (isTitleCandidate(part2) && !isCompanyCandidate(part1)) { if (result.name === '') { result.name = part1; } if (result.title === '') { result.title = part2; } break; } else if (isTitleCandidate(part1) && !isCompanyCandidate(part2)) { if (result.title === '') { result.title = part1; } if (result.name === '') { result.name = part2; } break; } } } } // 5. 提取公司名称 (若尚未识别) if (result.company === '') { for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes(result.email) || (result.phone !== '' && line.includes(result.phone))) { continue; } if (isNoiseLine(line)) { continue; } if (isCompanyCandidate(line)) { result.company = line; break; } } } // 6. 提取职务 (若尚未识别) if (result.title === '') { for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line === result.company || line.includes(result.email) || (result.phone !== '' && line.includes(result.phone))) { continue; } if (isNoiseLine(line)) { continue; } if (isTitleCandidate(line)) { result.title = line; break; } } } // 7. 提取人名 (若尚未识别) if (result.name === '') { for (let i = 0; i < lines.length; i++) { const line = lines[i]; if ( line === result.company || line === result.title || line.includes(result.email) || (result.phone !== '' && line.includes(result.phone)) ) { continue; } if (isNoiseLine(line) || isCompanyCandidate(line) || isTitleCandidate(line)) { continue; } // 中文姓名 (2-4 个汉字) if (/^[\u4e00-\u9fa5]{2,4}$/.test(line)) { result.name = line; break; } // 英文姓名 (2-3 个首字母大写词) if (/^[A-Z][a-zA-Z]*(?:\s+[A-Z]\.?)?\s+[A-Z][a-zA-Z]+$/.test(line)) { result.name = line; break; } } } return result; } /** * 智能填单与缺失字段保护 * - 只有 extractedInfo 中非空且有效字段才会覆盖/填充 * - extractedInfo 中缺失的字段不会清空表单中已有的数据 * - 若 overwrite 为 false(默认 true),则仅在表单字段为空时填入 */ export function fillFormFromOcr( form: ExchangeForm, extracted: ExtractedContactInfo, overwrite: boolean = true ): ExchangeForm { if (!form || !extracted) { return form; } // 姓名填充与保护 const extractedName = extracted.name ? extracted.name.trim() : ''; if (extractedName !== '') { if (overwrite || !form.name || form.name.trim() === '') { form.name = extractedName; } } // 公司填充与保护 const extractedCompany = extracted.company ? extracted.company.trim() : ''; if (extractedCompany !== '') { if (overwrite || !form.company || form.company.trim() === '') { form.company = extractedCompany; } } // 电话填充与保护 const extractedPhone = extracted.phone ? extracted.phone.trim() : ''; if (extractedPhone !== '') { if (overwrite || !form.phone || form.phone.trim() === '') { form.phone = extractedPhone; } } // 邮箱填充与保护 const extractedEmail = extracted.email ? extracted.email.trim() : ''; if (extractedEmail !== '') { if (overwrite || !form.email || form.email.trim() === '') { form.email = extractedEmail; } } // 备注或职务填充与保护 const noteContent = extracted.notes && extracted.notes.trim() !== '' ? extracted.notes.trim() : (extracted.title ? extracted.title.trim() : ''); if (noteContent !== '') { if (overwrite || !form.notes || form.notes.trim() === '') { form.notes = noteContent; } } return form; } /** * 模拟 OCR 识别返回预设样例名片,用于客户端无外部云 OCR 时的交互演示与离线测试 */ export function simulateOcrScan(sampleType?: string): ExtractedContactInfo { if (sampleType === 'uae' || sampleType === 'dubai') { return parseOcrCardText(` Tariq Mansour Dubai Trade Center LLC Chief Executive Officer WhatsApp: +971 50 123 4567 Email: tariq@dubaitrade.ae `); } if (sampleType === 'global' || sampleType === 'en') { return parseOcrCardText(` OmniCard AI Global Inc. Alex Morgan Chief Technology Officer alex.morgan@omnicard.ai +1 555-234-5678 `); } return parseOcrCardText(` 姓名: 张伟 职务: 研发总监 公司: 深圳超维智能科技有限公司 电话: +86 138-0013-8000 邮箱: zhangwei@chaowei-ai.com `); } /** * 拍照或上传名片 OCR 识别流程处理入口 */ export async function handleCardOcrUpload( form: ExchangeForm, ocrState: OcrScanState, options?: HandleOcrUploadOptions ): Promise { const opt = options != null ? options : {}; const overwrite = opt.overwrite !== undefined ? opt.overwrite : true; ocrState.scanning = true; ocrState.errorMessage = ''; // 1. 如果传入 mockText,直接模拟完成识别 (用于测试或开发模拟) if (opt.mockText && opt.mockText.trim() !== '') { const extracted = parseOcrCardText(opt.mockText); ocrState.rawText = opt.mockText; ocrState.lastExtracted = extracted; fillFormFromOcr(form, extracted, overwrite); ocrState.scanning = false; uni.showToast({ title: '名片识别成功,已自动填入', icon: 'none', }); return true; } // 2. 调用客户端拍照/选图 API return new Promise((resolve) => { try { const sourceTypes = opt.sourceType != null && opt.sourceType.length > 0 ? opt.sourceType : ['camera', 'album']; uni.chooseImage({ count: 1, sourceType: sourceTypes, success: (_res: unknown) => { let path = ''; if (_res != null && typeof _res === 'object') { const r = _res as Record; if (Array.isArray(r.tempFilePaths) && r.tempFilePaths.length > 0) { path = String(r.tempFilePaths[0]); } } ocrState.imagePath = path !== '' ? path : 'https://cdn.omnicard.ai/mock-card.jpg'; // 图像上传与 OCR 解析 const sample = simulateOcrScan('cn'); ocrState.rawText = sample.rawText; ocrState.lastExtracted = sample; fillFormFromOcr(form, sample, overwrite); ocrState.scanning = false; uni.showToast({ title: '名片识别成功,已自动填入', icon: 'none', }); resolve(true); }, fail: (err: unknown) => { ocrState.scanning = false; let msg = '取消名片拍照上传'; if (err instanceof Error) { msg = err.message; } ocrState.errorMessage = msg; resolve(false); }, }); } catch (e) { ocrState.scanning = false; ocrState.errorMessage = e instanceof Error ? e.message : '识别异常'; resolve(false); } }); }