feat(mpuniapp): 移动端拍照上传纸质名片 OCR 识别入库与留资表单联动 (Point 25)
这个提交包含在:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
322
mpuniapp/pages/portal/exchange.uvue
普通文件
322
mpuniapp/pages/portal/exchange.uvue
普通文件
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户