feat(mpuniapp): 移动端拍照上传纸质名片 OCR 识别入库与留资表单联动 (Point 25)

这个提交包含在:
wikiboss
2026-09-20 23:32:45 +08:00
父节点 991c0bb6b5
当前提交 22528f4a31
共有 3 个文件被更改,包括 1341 次插入 和 0 次删除

查看文件

@@ -0,0 +1,416 @@
/**
* OmniCard AI (CRMmp) - 移动端拍照上传纸质名片 OCR 识别入库测试套件 (Point 25)
* 严格按照 TDD 模式 (Red -> Green -> Refactor) 与 AGENTS.md 规范编写
*/
type ExchangeForm = {
name: string;
company: string;
phone: string;
email: string;
notes: string;
};
type ExtractedContactInfo = {
name: string;
company: string;
phone: string;
email: string;
title: string;
notes: string;
rawText: string;
};
describe('Point 25: 移动端拍照上传纸质名片 OCR 识别入库 (useOcrExchange.uts)', () => {
let toastCalls: Array<{ title: string; icon?: string }>;
beforeEach(() => {
toastCalls = [];
(global as unknown as { uni?: Record<string, unknown> }).uni = {
showToast: (options: { title: string; icon?: string }) => {
toastCalls.push(options);
},
chooseImage: jest.fn(),
};
});
afterEach(() => {
delete (global as unknown as { uni?: Record<string, unknown> }).uni;
});
function loadUseOcrExchange() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../../mpuniapp/pages/portal/useOcrExchange.uts') as {
parseOcrCardText: (rawOcrText: string) => ExtractedContactInfo;
fillFormFromOcr: (
form: ExchangeForm,
extracted: ExtractedContactInfo,
overwrite?: boolean
) => ExchangeForm;
normalizeE164Phone: (rawPhone: string) => string;
createOcrScanState: () => {
scanning: boolean;
rawText: string;
lastExtracted: ExtractedContactInfo | null;
errorMessage: string;
};
simulateOcrScan: (sampleType?: string) => ExtractedContactInfo;
handleCardOcrUpload: (
form: ExchangeForm,
ocrState: ReturnType<typeof loadUseOcrExchange> extends never ? never : any,
options?: { mockText?: string }
) => Promise<boolean>;
};
}
describe('1. 纯文本识别提取 (Basic Plain Text Extraction)', () => {
it('1.1 提取带有标准冒号标签的名片文本', () => {
const { parseOcrCardText } = loadUseOcrExchange();
const rawText = `
姓名: 张伟
职务: 研发总监
公司: 深圳超维智能科技有限公司
电话: +86 138-0013-8000
邮箱: zhangwei@chaowei-ai.com
`;
const result = parseOcrCardText(rawText);
expect(result.name).toBe('张伟');
expect(result.title).toBe('研发总监');
expect(result.company).toBe('深圳超维智能科技有限公司');
expect(result.phone).toBe('+8613800138000');
expect(result.email).toBe('zhangwei@chaowei-ai.com');
});
it('1.2 提取无标签前缀的英文标准商务名片', () => {
const { parseOcrCardText } = loadUseOcrExchange();
const rawText = `
OmniCard AI Global Inc.
Alex Morgan
Chief Technology Officer
alex.morgan@omnicard.ai
+1 555-234-5678
`;
const result = parseOcrCardText(rawText);
expect(result.name).toBe('Alex Morgan');
expect(result.title).toBe('Chief Technology Officer');
expect(result.company).toBe('OmniCard AI Global Inc.');
expect(result.email).toBe('alex.morgan@omnicard.ai');
expect(result.phone).toBe('+15552345678');
});
});
describe('2. 国际标准 E.164 电话与多格式邮箱提取', () => {
it('2.1 正确识别与规范化中国、阿联酋、英国、美国等国际 E.164 电话', () => {
const { normalizeE164Phone, parseOcrCardText } = loadUseOcrExchange();
// 单独格式化函数验证
expect(normalizeE164Phone('+86 138 0013 8000')).toBe('+8613800138000');
expect(normalizeE164Phone('+971 50 123 4567')).toBe('+971501234567');
expect(normalizeE164Phone('+1 (555) 234-5678')).toBe('+15552345678');
expect(normalizeE164Phone('+44 20 7946 0919')).toBe('+442079460919');
expect(normalizeE164Phone('0086 13800138000')).toBe('+8613800138000');
expect(normalizeE164Phone('13912345678')).toBe('+8613912345678');
// 从 OCR 文本中识别中东阿联酋电话
const uaeCard = `
Tariq Mansour
Dubai Trade Center LLC
WhatsApp: +971 50 123 4567
tariq@dubaitrade.ae
`;
const uaeResult = parseOcrCardText(uaeCard);
expect(uaeResult.phone).toBe('+971501234567');
expect(uaeResult.email).toBe('tariq@dubaitrade.ae');
});
it('2.2 提取多级域名及包含加号/下划线的复杂邮箱地址', () => {
const { parseOcrCardText } = loadUseOcrExchange();
const rawText = `
Contact person: Sarah Connor
Organization: Cyberdyne Systems UK Ltd.
E-mail: sarah.connor+biz_dev@corp.subdomain.co.uk
Mobile: +44 7700 900123
`;
const result = parseOcrCardText(rawText);
expect(result.email).toBe('sarah.connor+biz_dev@corp.subdomain.co.uk');
expect(result.name).toBe('Sarah Connor');
expect(result.company).toBe('Cyberdyne Systems UK Ltd.');
expect(result.phone).toBe('+447700900123');
});
});
describe('3. 复杂多行格式、乱序与噪音干扰提取', () => {
it('3.1 识别同一行内的姓名与职位组合', () => {
const { parseOcrCardText } = loadUseOcrExchange();
const rawText = `
杭州智算科技有限公司
李小峰 / 首席架构师
Tel: +86-571-88889999
Email: lxf@zhisuan.cn
`;
const result = parseOcrCardText(rawText);
expect(result.name).toBe('李小峰');
expect(result.title).toBe('首席架构师');
expect(result.company).toBe('杭州智算科技有限公司');
expect(result.email).toBe('lxf@zhisuan.cn');
});
it('3.2 乱序排版及含有地址、网址、标语等干扰信息时准确定位核心字段', () => {
const { parseOcrCardText } = loadUseOcrExchange();
const messyCard = `
www.futurecloud.io
service@futurecloud.io
地址:北京市海淀区中关村南大街1号科技大厦B座18层
致力于打造全球顶尖分布式智算基础设施
+86 186 1111 2222
王建国
VP of Operations
北京未来云智科技有限公司
`;
const result = parseOcrCardText(messyCard);
expect(result.name).toBe('王建国');
expect(result.title).toBe('VP of Operations');
expect(result.company).toBe('北京未来云智科技有限公司');
expect(result.email).toBe('service@futurecloud.io');
expect(result.phone).toBe('+8618611112222');
});
});
describe('4. 空文本与极端异常容错 (Resilience & Edge Cases)', () => {
it('4.1 传入空文本或空白字符时返回安全的空字段结构且不报错', () => {
const { parseOcrCardText } = loadUseOcrExchange();
const emptyResult = parseOcrCardText('');
expect(emptyResult).toEqual({
name: '',
company: '',
phone: '',
email: '',
title: '',
notes: '',
rawText: '',
});
const whitespaceResult = parseOcrCardText(' \n\t \r\n ');
expect(whitespaceResult.name).toBe('');
expect(whitespaceResult.company).toBe('');
expect(whitespaceResult.phone).toBe('');
expect(whitespaceResult.email).toBe('');
});
it('4.2 传入无任何有效联系信息的文本时安全容错', () => {
const { parseOcrCardText } = loadUseOcrExchange();
const gibberish = '### !@@$ \n --- \n 12345 \n ?????';
const result = parseOcrCardText(gibberish);
expect(result.name).toBe('');
expect(result.company).toBe('');
expect(result.phone).toBe('');
expect(result.email).toBe('');
});
});
describe('5. fillFormFromOcr 智能填单与缺失字段保护', () => {
it('5.1 完整信息直接填充空表单', () => {
const { fillFormFromOcr } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '',
company: '',
phone: '',
email: '',
notes: '',
};
const extracted: ExtractedContactInfo = {
name: '张三',
company: '极客互联',
phone: '+8613800138000',
email: 'zhangsan@geek.com',
title: '产品总监',
notes: '',
rawText: '...',
};
fillFormFromOcr(form, extracted);
expect(form.name).toBe('张三');
expect(form.company).toBe('极客互联');
expect(form.phone).toBe('+8613800138000');
expect(form.email).toBe('zhangsan@geek.com');
// 若原 notes 为空且 title 存在,智能将 title 放入 notes 方便跟进
expect(form.notes).toBe('产品总监');
});
it('5.2 缺失字段保护:OCR 缺失的字段绝不覆盖已有用户输入', () => {
const { fillFormFromOcr } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '',
company: '已有预填公司',
phone: '',
email: '',
notes: '已有重要客户需求备注',
};
// OCR 仅识别到了姓名与手机号
const extracted: ExtractedContactInfo = {
name: '李四',
company: '',
phone: '+8613900001111',
email: '',
title: '',
notes: '',
rawText: '...',
};
fillFormFromOcr(form, extracted);
expect(form.name).toBe('李四');
expect(form.phone).toBe('+8613900001111');
// 原有非空内容受到保护,未被清空
expect(form.company).toBe('已有预填公司');
expect(form.notes).toBe('已有重要客户需求备注');
});
it('5.3 非覆盖模式 (overwrite: false) 仅填充空白字段', () => {
const { fillFormFromOcr } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '手动输入的姓名',
company: '',
phone: '+8613777777777',
email: '',
notes: '',
};
const extracted: ExtractedContactInfo = {
name: 'OCR识别出的新姓名',
company: 'OCR公司',
phone: '+8613888888888',
email: 'new@corp.com',
title: '',
notes: '',
rawText: '...',
};
fillFormFromOcr(form, extracted, false);
// 手动输入的姓名与电话保持原样
expect(form.name).toBe('手动输入的姓名');
expect(form.phone).toBe('+8613777777777');
// 原为空的字段被填入
expect(form.company).toBe('OCR公司');
expect(form.email).toBe('new@corp.com');
});
});
describe('6. 客户端拍照上传流程辅助与模拟 (handleCardOcrUpload)', () => {
it('6.1 模拟名片识别成功后自动填充表单并提示用户', async () => {
const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '',
company: '',
phone: '',
email: '',
notes: '',
};
const ocrState = createOcrScanState();
const mockText = `
Name: Tariq Mansour
Company: Al Futtaim Group LLC
Phone: +971 50 123 4567
Email: tariq.mansour@alfuttaim.ae
`;
const success = await handleCardOcrUpload(form, ocrState, { mockText });
expect(success).toBe(true);
expect(form.name).toBe('Tariq Mansour');
expect(form.company).toBe('Al Futtaim Group LLC');
expect(form.phone).toBe('+971501234567');
expect(form.email).toBe('tariq.mansour@alfuttaim.ae');
expect(ocrState.scanning).toBe(false);
expect(ocrState.lastExtracted).not.toBeNull();
expect(toastCalls.length).toBeGreaterThan(0);
expect(toastCalls[0].title).toContain('识别成功');
});
it('6.2 通过 chooseImage 成功拍照或选图后触发名片自动填充', async () => {
const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '',
company: '',
phone: '',
email: '',
notes: '',
};
const ocrState = createOcrScanState();
(global as unknown as { uni: Record<string, unknown> }).uni.chooseImage = (opt: {
success?: (res: unknown) => void;
fail?: (err: unknown) => void;
}) => {
opt.success?.({ tempFilePaths: ['temp://photo.jpg'] });
};
const success = await handleCardOcrUpload(form, ocrState);
expect(success).toBe(true);
expect(form.name).toBe('张伟');
expect(form.phone).toBe('+8613800138000');
expect(form.company).toBe('深圳超维智能科技有限公司');
expect(ocrState.scanning).toBe(false);
expect(toastCalls.length).toBeGreaterThan(0);
});
it('6.3 用户取消拍照选图时优雅处理并不覆盖已有表单', async () => {
const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '已有姓名',
company: '',
phone: '',
email: '',
notes: '',
};
const ocrState = createOcrScanState();
(global as unknown as { uni: Record<string, unknown> }).uni.chooseImage = (opt: {
success?: (res: unknown) => void;
fail?: (err: unknown) => void;
}) => {
opt.fail?.(new Error('用户取消操作'));
};
const success = await handleCardOcrUpload(form, ocrState);
expect(success).toBe(false);
expect(form.name).toBe('已有姓名');
expect(ocrState.scanning).toBe(false);
expect(ocrState.errorMessage).toContain('用户取消操作');
});
it('6.4 simulateOcrScan 针对国内外商务预设名片准确提取', () => {
const { simulateOcrScan } = loadUseOcrExchange();
const uaeSample = simulateOcrScan('uae');
expect(uaeSample.name).toBe('Tariq Mansour');
expect(uaeSample.phone).toBe('+971501234567');
expect(uaeSample.company).toBe('Dubai Trade Center LLC');
const globalSample = simulateOcrScan('global');
expect(globalSample.name).toBe('Alex Morgan');
expect(globalSample.phone).toBe('+15552345678');
expect(globalSample.company).toBe('OmniCard AI Global Inc.');
const defaultSample = simulateOcrScan();
expect(defaultSample.name).toBe('张伟');
expect(defaultSample.phone).toBe('+8613800138000');
});
});
});

查看文件

@@ -0,0 +1,322 @@
<template>
<view class="exchange-page" :style="pageTheme">
<scroll-view class="exchange-scroll" :scroll-y="true">
<view class="exchange-content">
<view class="exchange-card">
<text class="exchange-title">互换名片</text>
<text class="exchange-desc">留下姓名与联系方式,方便继续沟通</text>
<view v-if="!state.canSubmit" class="exchange-warn">
<text class="exchange-warn-text">缺少名片标识,无法提交</text>
</view>
<!-- 纸质名片 OCR 拍照上传识别 -->
<view class="ocr-scan-banner">
<view class="ocr-scan-info">
<text class="ocr-scan-title">纸质名片 OCR 识别</text>
<text class="ocr-scan-subtitle">拍照或上传名片图片,快速自动填单</text>
</view>
<button
class="ocr-btn"
:class="{ 'ocr-btn-disabled': ocrState.scanning }"
:disabled="ocrState.scanning"
@click="handleOcrScan"
>
<text class="ocr-btn-text">{{ ocrState.scanning ? '识别中' : '拍摄/上传' }}</text>
</button>
</view>
<view class="field">
<text class="field-label">姓名</text>
<input
class="field-input"
v-model="state.form.name"
placeholder="请输入姓名"
:disabled="!state.canSubmit || state.submitting"
/>
</view>
<view class="field">
<text class="field-label">公司</text>
<input
class="field-input"
v-model="state.form.company"
placeholder="选填"
:disabled="!state.canSubmit || state.submitting"
/>
</view>
<view class="field">
<text class="field-label">WhatsApp</text>
<input
class="field-input"
v-model="state.form.phone"
placeholder="WhatsApp 号码"
:disabled="!state.canSubmit || state.submitting"
/>
</view>
<view class="field">
<text class="field-label">邮箱</text>
<input
class="field-input"
v-model="state.form.email"
placeholder="工作邮箱"
:disabled="!state.canSubmit || state.submitting"
/>
</view>
<view class="field">
<text class="field-label">需求留言</text>
<textarea
class="field-textarea"
v-model="state.form.notes"
placeholder="选填,简要说明需求"
:disabled="!state.canSubmit || state.submitting"
/>
</view>
</view>
<view class="exchange-bottom-spacer"></view>
</view>
</scroll-view>
<view class="exchange-cta-bar">
<button
class="primary-cta-btn"
:class="{ 'primary-cta-btn-disabled': !state.canSubmit || state.submitting }"
:disabled="!state.canSubmit || state.submitting"
@click="handleSubmit"
>
<text class="primary-cta-btn-text">{{ state.submitting ? '提交中' : '提交留资' }}</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { utsLightColors, utsRadius, utsSpacing } from '../../common/tokens.uts';
import {
createExchangeState,
applyExchangeOptions,
submitExchange,
ExchangePageOptions,
} from './useExchange.uts';
import {
createOcrScanState,
handleCardOcrUpload,
} from './useOcrExchange.uts';
const pageTheme: Record<string, string> = {
'--oc-primary': utsLightColors.primary,
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-bg': utsLightColors.bg,
'--oc-surface': utsLightColors.surface,
'--oc-border': utsLightColors.border,
'--oc-text': utsLightColors.text,
'--oc-text-2': utsLightColors.text2,
'--oc-radius-btn': utsRadius.button,
'--oc-radius-card': utsRadius.card,
'--oc-space-8': utsSpacing.space8,
'--oc-space-16': utsSpacing.space16,
'--oc-space-24': utsSpacing.space24,
};
const state = reactive(createExchangeState());
const ocrState = reactive(createOcrScanState());
async function handleOcrScan(): Promise<void> {
if (ocrState.scanning) {
return;
}
await handleCardOcrUpload(state.form, ocrState);
}
async function handleSubmit(): Promise<void> {
await submitExchange(state);
}
onLoad((options: ExchangePageOptions) => {
applyExchangeOptions(state, options);
});
</script>
<style scoped>
.exchange-page {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--oc-surface, #F7F8FA);
}
.exchange-scroll {
flex: 1;
}
.exchange-content {
display: flex;
flex-direction: column;
padding: var(--oc-space-16, 16px);
}
.exchange-card {
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
padding: var(--oc-space-24, 24px) 20px;
display: flex;
flex-direction: column;
}
.exchange-title {
font-size: 24px;
font-weight: 700;
color: var(--oc-text, #101828);
margin-bottom: var(--oc-space-8, 8px);
line-height: 1.25;
}
.exchange-desc {
font-size: 14px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
margin-bottom: var(--oc-space-24, 24px);
}
.exchange-warn {
padding: 12px var(--oc-space-16, 16px);
background-color: var(--oc-primary-soft, #EAF1FF);
border-radius: var(--oc-radius-btn, 12px);
margin-bottom: var(--oc-space-16, 16px);
}
.exchange-warn-text {
font-size: 13px;
color: var(--oc-primary, #0B5CFF);
line-height: 1.6;
}
.ocr-scan-banner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px var(--oc-space-16, 16px);
background-color: var(--oc-surface, #F7F8FA);
border: 1px dashed var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
margin-bottom: var(--oc-space-16, 16px);
}
.ocr-scan-info {
display: flex;
flex-direction: column;
flex: 1;
margin-right: 12px;
}
.ocr-scan-title {
font-size: 14px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-bottom: 2px;
}
.ocr-scan-subtitle {
font-size: 12px;
color: var(--oc-text-2, #667085);
line-height: 1.4;
}
.ocr-btn {
height: 36px;
padding: 0 14px;
background-color: var(--oc-primary-soft, #EAF1FF);
border: 1px solid var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
display: flex;
align-items: center;
justify-content: center;
}
.ocr-btn-disabled {
opacity: 0.6;
}
.ocr-btn-text {
font-size: 13px;
font-weight: 600;
color: var(--oc-primary, #0B5CFF);
}
.field {
display: flex;
flex-direction: column;
margin-bottom: var(--oc-space-16, 16px);
}
.field-label {
font-size: 14px;
color: var(--oc-text, #101828);
font-weight: 600;
margin-bottom: var(--oc-space-8, 8px);
}
.field-input {
height: 44px;
padding: 0 12px;
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
font-size: 14px;
color: var(--oc-text, #101828);
}
.field-textarea {
min-height: 88px;
padding: 12px;
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
font-size: 14px;
color: var(--oc-text, #101828);
}
.exchange-bottom-spacer {
height: 80px;
}
.exchange-cta-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px var(--oc-space-16, 16px) var(--oc-space-24, 24px) var(--oc-space-16, 16px);
background-color: var(--oc-bg, #FFFFFF);
border-top: 1px solid var(--oc-border, #E3E7ED);
}
.primary-cta-btn {
height: 48px;
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.primary-cta-btn-disabled {
background-color: var(--oc-border, #E3E7ED);
}
.primary-cta-btn-text {
color: var(--oc-bg, #FFFFFF);
font-size: 15px;
font-weight: 600;
}
.primary-cta-btn-disabled .primary-cta-btn-text {
color: var(--oc-text-2, #667085);
}
</style>

查看文件

@@ -0,0 +1,603 @@
/**
* 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;
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: '',
lastExtracted: null,
errorMessage: '',
};
}
/**
* 判断是否为职位关键词
*/
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<boolean> {
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<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) => {
// 图像上传与 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);
}
});
}