补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
753 行
20 KiB
Plaintext
753 行
20 KiB
Plaintext
/**
|
||
* OmniCard AI (CRMmp) - 移动端拍照上传纸质名片 OCR 识别入库业务驱动层 (Point 25)
|
||
* 严格遵循 uts 强类型规范、零 emoji 与 AGENTS.md 约束
|
||
*/
|
||
|
||
import { ExchangeForm } from './useExchange.uts';
|
||
import { httpPost } from '../../common/request.uts';
|
||
|
||
export type ExtractedContactInfo = {
|
||
name: string;
|
||
company: string;
|
||
phone: string;
|
||
email: string;
|
||
title: string;
|
||
notes: string;
|
||
rawText: string;
|
||
};
|
||
|
||
export type OcrApiRecognizeResult = {
|
||
name: string;
|
||
company: string;
|
||
phone: string;
|
||
email: string;
|
||
title: string;
|
||
};
|
||
|
||
export type OcrScanState = {
|
||
scanning: boolean;
|
||
rawText: string;
|
||
imagePath: string;
|
||
lastExtracted: ExtractedContactInfo | null;
|
||
errorMessage: string;
|
||
};
|
||
|
||
export type HandleOcrUploadOptions = {
|
||
mockText?: string;
|
||
sourceType?: string[];
|
||
overwrite?: boolean;
|
||
httpPost?: (
|
||
url: string,
|
||
data?: Record<string, string>
|
||
) => Promise<OcrApiRecognizeResult>;
|
||
};
|
||
|
||
/**
|
||
* 规范化国际 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 识别返回预设样例名片,仅供离线演示/单测;真实上传路径不得依赖此函数
|
||
*/
|
||
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 function mapOcrApiResult(apiResult: OcrApiRecognizeResult): ExtractedContactInfo {
|
||
const name = apiResult != null && apiResult.name != null ? String(apiResult.name).trim() : '';
|
||
const company =
|
||
apiResult != null && apiResult.company != null ? String(apiResult.company).trim() : '';
|
||
const phoneRaw =
|
||
apiResult != null && apiResult.phone != null ? String(apiResult.phone).trim() : '';
|
||
const email = apiResult != null && apiResult.email != null ? String(apiResult.email).trim() : '';
|
||
const title = apiResult != null && apiResult.title != null ? String(apiResult.title).trim() : '';
|
||
const phone = phoneRaw !== '' ? normalizeE164Phone(phoneRaw) : '';
|
||
|
||
return {
|
||
name,
|
||
company,
|
||
phone,
|
||
email,
|
||
title,
|
||
notes: '',
|
||
rawText: [name, title, company, phone, email].filter((v) => v !== '').join('\n'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 读取本地图片为 base64;不可读时返回空串
|
||
*/
|
||
function readLocalImageBase64(filePath: string): Promise<string> {
|
||
return new Promise<string>((resolve) => {
|
||
try {
|
||
if (typeof uni === 'undefined' || typeof uni.getFileSystemManager !== 'function') {
|
||
resolve('');
|
||
return;
|
||
}
|
||
const fs = uni.getFileSystemManager();
|
||
if (fs == null || typeof fs.readFile !== 'function') {
|
||
resolve('');
|
||
return;
|
||
}
|
||
fs.readFile({
|
||
filePath: filePath,
|
||
encoding: 'base64',
|
||
success: (res: unknown) => {
|
||
let data = '';
|
||
if (res != null && typeof res === 'object') {
|
||
const r = res as Record<string, unknown>;
|
||
if (r.data != null) {
|
||
data = String(r.data);
|
||
}
|
||
}
|
||
resolve(data);
|
||
},
|
||
fail: (_err: unknown) => {
|
||
resolve('');
|
||
},
|
||
});
|
||
} catch (_e) {
|
||
resolve('');
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 根据选图路径组装 OCR 请求体:优先 imageBase64,其次 imageUrl
|
||
*/
|
||
async function buildOcrRecognizeBody(
|
||
imagePath: string
|
||
): Promise<Record<string, string>> {
|
||
const path = imagePath.trim();
|
||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||
return { imageUrl: path };
|
||
}
|
||
|
||
const base64 = await readLocalImageBase64(path);
|
||
if (base64 !== '') {
|
||
return { imageBase64: base64 };
|
||
}
|
||
|
||
return { imageUrl: path };
|
||
}
|
||
|
||
/**
|
||
* 拍照或上传名片 OCR 识别流程处理入口
|
||
*/
|
||
export async function handleCardOcrUpload(
|
||
form: ExchangeForm,
|
||
ocrState: OcrScanState,
|
||
options?: HandleOcrUploadOptions
|
||
): Promise<boolean> {
|
||
const opt = options != null ? options : {};
|
||
const overwrite = opt.overwrite !== undefined ? opt.overwrite : true;
|
||
const postFn =
|
||
opt.httpPost != null
|
||
? opt.httpPost
|
||
: (url: string, data?: Record<string, string>) =>
|
||
httpPost<OcrApiRecognizeResult>(url, data);
|
||
|
||
ocrState.scanning = true;
|
||
ocrState.errorMessage = '';
|
||
|
||
// 1. 如果传入 mockText,走本地 parseOcrCardText(供测试/离线模拟)
|
||
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,成功后请求后端 OCR
|
||
return new Promise<boolean>((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<string, unknown>;
|
||
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';
|
||
|
||
void (async () => {
|
||
try {
|
||
const body = await buildOcrRecognizeBody(ocrState.imagePath);
|
||
const apiResult = await postFn('/open/ai/ocr/recognize', body);
|
||
const extracted = mapOcrApiResult(apiResult);
|
||
ocrState.rawText = extracted.rawText;
|
||
ocrState.lastExtracted = extracted;
|
||
fillFormFromOcr(form, extracted, overwrite);
|
||
ocrState.scanning = false;
|
||
ocrState.errorMessage = '';
|
||
|
||
uni.showToast({
|
||
title: '名片识别成功,已自动填入',
|
||
icon: 'none',
|
||
});
|
||
resolve(true);
|
||
} catch (err: unknown) {
|
||
ocrState.scanning = false;
|
||
let msg = '名片识别失败';
|
||
if (err instanceof Error && err.message !== '') {
|
||
msg = err.message;
|
||
}
|
||
ocrState.errorMessage = msg;
|
||
resolve(false);
|
||
}
|
||
})();
|
||
},
|
||
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);
|
||
}
|
||
});
|
||
}
|