refactor(crm): 潜客详情与跟进页面深度重构与出海触达优化
这个提交包含在:
@@ -88,12 +88,24 @@ type UseLeadDetailModule = {
|
||||
existing: string | null | undefined,
|
||||
next: string | null | undefined
|
||||
) => string;
|
||||
appendStructuredNote: (
|
||||
existing: string | null | undefined,
|
||||
next: string | null | undefined,
|
||||
attachments?: string[]
|
||||
) => string;
|
||||
formatWhatsAppUrl: (phone: string | null | undefined) => string;
|
||||
generateAiIcebreaker: (
|
||||
lead: LeadEntity | null,
|
||||
tracks: RadarTrack[],
|
||||
locale?: string
|
||||
) => string;
|
||||
saveNote: (
|
||||
input: {
|
||||
id: string | number;
|
||||
notes?: string;
|
||||
next: string;
|
||||
status?: LeadStatus;
|
||||
attachments?: string[];
|
||||
},
|
||||
api?: SaveNoteApi
|
||||
) => Promise<LeadEntity>;
|
||||
@@ -262,6 +274,42 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', ()
|
||||
expect(result.notes).toBe('Met at GITEX\nAsked for quote');
|
||||
expect(result.status).toBe('hot');
|
||||
});
|
||||
|
||||
it('3.3 updateLeadStage 调用 updateOwnerLeadStatus 推进商机阶段', async () => {
|
||||
const { updateLeadStage } = loadUseLeadDetail() as UseLeadDetailModule & {
|
||||
updateLeadStage: (
|
||||
state: Record<string, unknown>,
|
||||
newStatus: LeadStatus,
|
||||
api?: SaveNoteApi & {
|
||||
updateOwnerLeadStatus?: (
|
||||
id: string | number,
|
||||
status: LeadStatus
|
||||
) => Promise<LeadEntity>;
|
||||
}
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
const lead = buildLead({ status: 'new' });
|
||||
const state: Record<string, unknown> = {
|
||||
leadId: 21,
|
||||
lead,
|
||||
status: 'new',
|
||||
saving: false,
|
||||
errorMessage: '',
|
||||
};
|
||||
const updateOwnerLeadStatus = jest.fn(
|
||||
async (id: string | number, status: LeadStatus) => {
|
||||
expect(id).toBe(21);
|
||||
expect(status).toBe('hot');
|
||||
return buildLead({ id, status });
|
||||
}
|
||||
);
|
||||
|
||||
const ok = await updateLeadStage(state, 'hot', { updateOwnerLeadStatus });
|
||||
expect(ok).toBe(true);
|
||||
expect(state.status).toBe('hot');
|
||||
expect(state.saving).toBe(false);
|
||||
expect(updateOwnerLeadStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. resolveLeadDetailText 只增文案', () => {
|
||||
@@ -272,12 +320,58 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', ()
|
||||
expect(resolveLeadDetailText('comingSoon')).toBe('即将提供');
|
||||
expect(resolveLeadDetailText('uploadHint').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('save').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('stageTitle').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('historyTitle').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('stageUpdated').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('copiedToast').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('whatsappAction').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('saveContactAction').length).toBeGreaterThan(0);
|
||||
expect(resolveLeadDetailText('generateAiAction').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 销售触达与 AI 赋能函数', () => {
|
||||
it('5.1 formatWhatsAppUrl 清洗国际号码并生成 wa.me 链接', () => {
|
||||
const { formatWhatsAppUrl } = loadUseLeadDetail();
|
||||
|
||||
expect(formatWhatsAppUrl('+971-50-1234567')).toBe('https://wa.me/971501234567');
|
||||
expect(formatWhatsAppUrl('0086 138-0000-0000')).toBe('https://wa.me/8613800000000');
|
||||
expect(formatWhatsAppUrl('')).toBe('');
|
||||
expect(formatWhatsAppUrl(null)).toBe('');
|
||||
expect(formatWhatsAppUrl(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('5.2 generateAiIcebreaker 生成个性化破冰销售话术', () => {
|
||||
const { generateAiIcebreaker } = loadUseLeadDetail();
|
||||
const lead = buildLead({ name: 'Elena Rostova', company: 'Dubai Global Trading' });
|
||||
const tracks = [buildTrack({ viewedBlocks: ['case', 'pricing'] })];
|
||||
|
||||
const scriptZh = generateAiIcebreaker(lead, tracks, 'zh-CN');
|
||||
expect(scriptZh).toContain('Elena Rostova');
|
||||
expect(scriptZh.length).toBeGreaterThan(20);
|
||||
|
||||
const scriptEn = generateAiIcebreaker(lead, tracks, 'en-US');
|
||||
expect(scriptEn).toContain('Elena');
|
||||
expect(scriptEn.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('5.3 appendStructuredNote 结构化拼接备忘与附件链接', () => {
|
||||
const { appendStructuredNote } = loadUseLeadDetail();
|
||||
|
||||
const resWithAttach = appendStructuredNote(
|
||||
'Initial meeting',
|
||||
'Follow-up call agreed',
|
||||
['https://cdn.example.com/card.jpg']
|
||||
);
|
||||
expect(resWithAttach).toContain('Initial meeting');
|
||||
expect(resWithAttach).toContain('Follow-up call agreed');
|
||||
expect(resWithAttach).toContain('https://cdn.example.com/card.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () => {
|
||||
it('页面存在,足迹用 list-view,备忘用 textarea,单一主 CTA', () => {
|
||||
it('页面存在,足迹用 list-view,备忘用 textarea,单一主 CTA,支持 WhatsApp 与 AI 破冰', () => {
|
||||
expect(fs.existsSync(DETAIL_PAGE)).toBe(true);
|
||||
expect(fs.existsSync(DETAIL_UTS)).toBe(true);
|
||||
|
||||
@@ -294,17 +388,24 @@ describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () =>
|
||||
expect(pageSource.includes('<textarea')).toBe(true);
|
||||
expect(pageSource.includes('min-height: 88px')).toBe(true);
|
||||
expect(pageSource.includes('primary-cta-btn')).toBe(true);
|
||||
expect(template.includes('WhatsApp')).toBe(false);
|
||||
expect(pageSource.includes('calc(88px + env(safe-area-inset-bottom, 0px))')).toBe(
|
||||
true
|
||||
);
|
||||
expect(pageSource.includes('lead-status-hot')).toBe(true);
|
||||
expect(pageSource.includes('#F04438')).toBe(true);
|
||||
expect(pageSource.includes('aiScript')).toBe(true);
|
||||
expect(pageSource.includes('comingSoon')).toBe(true);
|
||||
expect(pageSource.includes('uploadHint')).toBe(true);
|
||||
expect(pageSource.includes('image-uploader')).toBe(true);
|
||||
expect(logicSource.includes('async function submitLeadNote')).toBe(true);
|
||||
expect(
|
||||
combined.includes('handleWhatsApp') || logicSource.includes('formatWhatsAppUrl')
|
||||
).toBe(true);
|
||||
expect(
|
||||
combined.includes('handleSaveContact') || logicSource.includes('saveContactToDevice')
|
||||
).toBe(true);
|
||||
expect(
|
||||
combined.includes('handleGenerateAiScript') || logicSource.includes('generateAiIcebreaker')
|
||||
).toBe(true);
|
||||
expect(
|
||||
combined.includes('fetchOwnerLeadInfo') || combined.includes('loadDetail')
|
||||
).toBe(true);
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
fetchOwnerLeadInfo,
|
||||
fetchOwnerRadarPage,
|
||||
updateOwnerLeadStatus,
|
||||
} from '../../common/api.uts';
|
||||
import { httpPost } from '../../common/request.uts';
|
||||
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
|
||||
@@ -32,7 +33,22 @@ export type LeadDetailCopyKey =
|
||||
| 'error'
|
||||
| 'loading'
|
||||
| 'stay'
|
||||
| 'depth';
|
||||
| 'depth'
|
||||
| 'aiScript'
|
||||
| 'comingSoon'
|
||||
| 'uploadHint'
|
||||
| 'stageTitle'
|
||||
| 'historyTitle'
|
||||
| 'stageUpdated'
|
||||
| 'copiedToast'
|
||||
| 'sourceLabel'
|
||||
| 'whatsappAction'
|
||||
| 'saveContactAction'
|
||||
| 'generateAiAction'
|
||||
| 'copyScriptAction'
|
||||
| 'scriptCopiedToast'
|
||||
| 'savedContactToast'
|
||||
| 'leadTitle';
|
||||
|
||||
export type LeadDetailPageOptions = {
|
||||
id?: string;
|
||||
@@ -57,6 +73,7 @@ export type SaveNoteInput = {
|
||||
notes?: string;
|
||||
next: string;
|
||||
status?: LeadStatus;
|
||||
attachments?: string[];
|
||||
};
|
||||
|
||||
export type SaveNoteApi = {
|
||||
@@ -87,6 +104,8 @@ export type LeadDetailState = {
|
||||
empty: boolean;
|
||||
missingId: boolean;
|
||||
errorMessage: string;
|
||||
aiScript: string;
|
||||
aiGenerating: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
@@ -142,6 +161,53 @@ export function resolveLeadDetailText(key: LeadDetailCopyKey): string {
|
||||
if (key === 'depth') {
|
||||
return zh ? '滚动深度' : 'Scroll depth';
|
||||
}
|
||||
if (key === 'aiScript') {
|
||||
return zh
|
||||
? '根据访问轨迹生成跟进话术'
|
||||
: 'Talking points will be generated from this visit trail';
|
||||
}
|
||||
if (key === 'comingSoon') {
|
||||
return zh ? '即将提供' : 'Coming soon';
|
||||
}
|
||||
if (key === 'uploadHint') {
|
||||
return zh ? '可附加现场照片或名片图' : 'Add a meeting photo or card image';
|
||||
}
|
||||
if (key === 'stageTitle') {
|
||||
return zh ? '商机阶段' : 'Pipeline Stage';
|
||||
}
|
||||
if (key === 'historyTitle') {
|
||||
return zh ? '历史跟进纪要' : 'Follow-up History';
|
||||
}
|
||||
if (key === 'stageUpdated') {
|
||||
return zh ? '阶段已更新' : 'Stage updated';
|
||||
}
|
||||
if (key === 'copiedToast') {
|
||||
return zh ? '已复制到剪贴板' : 'Copied to clipboard';
|
||||
}
|
||||
if (key === 'sourceLabel') {
|
||||
return zh ? '触达来源' : 'Source';
|
||||
}
|
||||
if (key === 'whatsappAction') {
|
||||
return 'WhatsApp';
|
||||
}
|
||||
if (key === 'saveContactAction') {
|
||||
return zh ? '保存到通讯录' : 'Save Contact';
|
||||
}
|
||||
if (key === 'generateAiAction') {
|
||||
return zh ? '一键生成破冰话术' : 'Generate Script';
|
||||
}
|
||||
if (key === 'copyScriptAction') {
|
||||
return zh ? '复制话术' : 'Copy Script';
|
||||
}
|
||||
if (key === 'scriptCopiedToast') {
|
||||
return zh ? '话术已复制' : 'Script copied';
|
||||
}
|
||||
if (key === 'savedContactToast') {
|
||||
return zh ? '联系人已保存' : 'Contact saved';
|
||||
}
|
||||
if (key === 'leadTitle') {
|
||||
return zh ? '潜客档案' : 'Lead Profile';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -207,6 +273,133 @@ export function appendNote(
|
||||
return `${prev}\n${extra}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗手机号并生成 WhatsApp 直达链接 (wa.me)
|
||||
*/
|
||||
export function formatWhatsAppUrl(rawPhone: string | null | undefined): string {
|
||||
if (rawPhone == null) {
|
||||
return '';
|
||||
}
|
||||
const trimmed = String(rawPhone).trim();
|
||||
if (trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
let digits = trimmed.replace(/[^0-9]/g, '');
|
||||
if (digits.startsWith('00')) {
|
||||
digits = digits.slice(2);
|
||||
}
|
||||
if (digits === '') {
|
||||
return '';
|
||||
}
|
||||
return `https://wa.me/${digits}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能破冰话术生成器 (基于客户姓名、公司、渠道与意向板块)
|
||||
*/
|
||||
export function generateAiIcebreaker(
|
||||
lead: LeadEntity | null,
|
||||
tracks: RadarTrack[],
|
||||
locale?: string
|
||||
): string {
|
||||
if (lead == null) {
|
||||
return '';
|
||||
}
|
||||
const currentLoc =
|
||||
locale != null && locale !== '' ? locale : getCurrentLocale();
|
||||
const isZh = currentLoc === 'zh-CN';
|
||||
const name = lead.name.trim();
|
||||
const company =
|
||||
lead.company != null && lead.company.trim() !== ''
|
||||
? lead.company.trim()
|
||||
: '';
|
||||
const source =
|
||||
lead.source != null ? String(lead.source).toUpperCase() : 'OMNICARD';
|
||||
|
||||
let keyTopic = '';
|
||||
if (tracks.length > 0) {
|
||||
for (const track of tracks) {
|
||||
if (track.viewedBlocks != null && track.viewedBlocks.length > 0) {
|
||||
keyTopic = track.viewedBlocks[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isZh) {
|
||||
const companyPart = company !== '' ? `${company}的` : '';
|
||||
const topicPart = keyTopic !== '' ? `与【${keyTopic}】` : '';
|
||||
return `您好 ${name},我是来自 OmniCard 的商务顾问。注意到您此前通过 ${source} 触达并关注了我们${companyPart}智能数字名片${topicPart}方案。请问您近期是否有具体的数字化升级或商务展会名片需求?我可为您准备一份定制演示方案。`;
|
||||
}
|
||||
|
||||
const firstWord = name.split(' ')[0];
|
||||
const companyPart = company !== '' ? ` for ${company}` : '';
|
||||
return `Hi ${firstWord}, this is the OmniCard team. Noticed you recently connected via ${source} and explored our digital identity solutions. Would love to see if you have any questions or if you'd like a quick tailored demo${companyPart}. Looking forward to connecting!`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化拼接备忘:将附件图片持久化嵌入备忘文本,避免数据丢失
|
||||
*/
|
||||
export function appendStructuredNote(
|
||||
existing: string | null | undefined,
|
||||
next: string | null | undefined,
|
||||
attachments?: string[]
|
||||
): string {
|
||||
const prev = existing != null ? String(existing).trim() : '';
|
||||
let extra = next != null ? String(next).trim() : '';
|
||||
if (attachments != null && attachments.length > 0) {
|
||||
const attachList = attachments.filter((url) => url.trim() !== '');
|
||||
if (attachList.length > 0) {
|
||||
const attachText = attachList.join(' ');
|
||||
extra = extra !== '' ? `${extra}\n[${attachText}]` : `[${attachText}]`;
|
||||
}
|
||||
}
|
||||
if (prev === '') {
|
||||
return extra;
|
||||
}
|
||||
if (extra === '') {
|
||||
return prev;
|
||||
}
|
||||
return `${prev}\n${extra}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到手机系统通讯录 (vCard 兼容)
|
||||
*/
|
||||
export async function saveContactToDevice(
|
||||
lead: LeadEntity | null
|
||||
): Promise<boolean> {
|
||||
if (lead == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (typeof uni.addPhoneContact === 'function') {
|
||||
uni.addPhoneContact({
|
||||
firstName: lead.name,
|
||||
organization: lead.company != null ? lead.company : '',
|
||||
mobilePhoneNumber: lead.phone != null ? lead.phone : '',
|
||||
email: lead.email != null ? lead.email : '',
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: resolveLeadDetailText('savedContactToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
const contactText = `${lead.name} ${
|
||||
lead.phone != null ? lead.phone : ''
|
||||
} ${lead.email != null ? lead.email : ''}`.trim();
|
||||
uni.setClipboardData({
|
||||
data: contactText,
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error && err.message !== '') {
|
||||
return err.message;
|
||||
@@ -286,13 +479,17 @@ export async function loadDetail(
|
||||
}
|
||||
|
||||
/**
|
||||
* 只更新 notes:POST /admin/crm/lead/update { id, notes }
|
||||
* 更新 notes(支持附件结构化嵌入):POST /admin/crm/lead/update { id, notes }
|
||||
*/
|
||||
export async function saveNote(
|
||||
input: SaveNoteInput,
|
||||
api?: SaveNoteApi
|
||||
): Promise<LeadEntity> {
|
||||
const notes = appendNote(input.notes, input.next);
|
||||
const notes = appendStructuredNote(
|
||||
input.notes,
|
||||
input.next,
|
||||
input.attachments
|
||||
);
|
||||
const post =
|
||||
api != null && api.httpPost != null ? api.httpPost : httpPost;
|
||||
const payload: Record<string, string | number> = {
|
||||
@@ -320,6 +517,8 @@ export function createLeadDetailState(): LeadDetailState {
|
||||
empty: false,
|
||||
missingId: false,
|
||||
errorMessage: '',
|
||||
aiScript: '',
|
||||
aiGenerating: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -418,11 +617,13 @@ export async function submitLeadNote(
|
||||
notes: state.existingNotes,
|
||||
next,
|
||||
status: state.lead != null ? state.lead.status : undefined,
|
||||
attachments: state.attachments,
|
||||
},
|
||||
api
|
||||
);
|
||||
applyLeadToState(state, saved);
|
||||
state.nextNote = '';
|
||||
state.attachments = [];
|
||||
state.saving = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -432,6 +633,35 @@ export async function submitLeadNote(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLeadStage(
|
||||
state: LeadDetailState,
|
||||
newStatus: LeadStatus,
|
||||
api?: SaveNoteApi
|
||||
): Promise<boolean> {
|
||||
if (state.leadId == null || state.saving) {
|
||||
return false;
|
||||
}
|
||||
const prevStatus = state.status;
|
||||
state.status = newStatus;
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const updateFn =
|
||||
api != null && api.updateOwnerLeadStatus != null
|
||||
? api.updateOwnerLeadStatus
|
||||
: updateOwnerLeadStatus;
|
||||
const updated = await updateFn(state.leadId, newStatus);
|
||||
applyLeadToState(state, updated);
|
||||
state.saving = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
state.status = prevStatus;
|
||||
state.saving = false;
|
||||
state.errorMessage = readErrorMessage(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function goBackFromLeadDetail(): void {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
|
||||
在新工单中引用
屏蔽一个用户