/** * OmniCard AI (CRMmp) - 名片主潜客详情与跟进备忘业务驱动层 * 拉线索详情 + 足迹分页,备忘只追加不覆盖 */ import { LeadEntity, LeadStatus, OwnerPageData, OwnerPageQuery, RadarTrack, CardProfile, } from '../../common/types.uts'; import { fetchOwnerLeadInfo, fetchOwnerRadarPage, updateOwnerLeadStatus, } from '../../common/api.uts'; import { httpPost } from '../../common/request.uts'; import { getCurrentLocale, getOmniText } from '../../common/locale.uts'; import { statusLabel } from './useLeads.uts'; import { formatDepth, formatLocation, formatStay } from './useRadar.uts'; import { saveToContacts } from '../../common/vcard-export.uts'; export type LeadDetailCopyKey = | 'title' | 'score' | 'timeline' | 'notes' | 'notesPlaceholder' | 'save' | 'saving' | 'back' | 'empty' | 'missingId' | 'error' | 'loading' | 'stay' | 'depth' | 'aiScript' | 'comingSoon' | 'uploadHint' | 'stageTitle' | 'historyTitle' | 'stageUpdated' | 'copiedToast' | 'sourceLabel' | 'whatsappAction' | 'saveContactAction' | 'generateAiAction' | 'copyScriptAction' | 'applyScriptAction' | 'scriptCopiedToast' | 'savedContactToast' | 'leadTitle' | 'aiScriptTitle' | 'callFailed' | 'noteSavedToast' | 'notesRequiredToast'; export type LeadDetailPageOptions = { id?: string; }; export type LoadDetailApi = { fetchOwnerLeadInfo?: (id: string | number) => Promise; fetchOwnerRadarPage?: ( query: OwnerPageQuery ) => Promise>; }; export type LoadDetailResult = { lead: LeadEntity | null; tracks: RadarTrack[]; empty: boolean; errorMessage: string; }; export type SaveNoteInput = { id: string | number; notes?: string; next: string; status?: LeadStatus; attachments?: string[]; }; export type SaveNoteApi = { httpPost?: ( url: string, data: Record ) => Promise; updateOwnerLeadStatus?: ( id: string | number, status: LeadStatus ) => Promise; }; export type SaveNoteResult = { ok: boolean; code?: number; lead: LeadEntity | null; errorMessage: string; }; export type LeadDetailState = { rawId: string; leadId: string | number | null; lead: LeadEntity | null; name: string; company: string; phone: string; email: string; source: string; status: string; intentScore: number; existingNotes: string; nextNote: string; attachments: string[]; tracks: RadarTrack[]; loading: boolean; saving: boolean; empty: boolean; missingId: boolean; errorMessage: string; aiScript: string; aiGenerating: boolean; }; const DEFAULT_PAGE = 1; const DEFAULT_SIZE = 20; function isZhLocale(): boolean { return getCurrentLocale() === 'zh-CN'; } /** * 页面文案:标题 / 意向 / 轨迹 / 保存走词典,缺 id 与占位走本地词典 */ export function resolveLeadDetailText(key: LeadDetailCopyKey): string { const locale = getCurrentLocale(); if (key === 'title') { return getOmniText(locale, 'crm.follow_up'); } if (key === 'score') { return getOmniText(locale, 'radar.lead_score'); } if (key === 'timeline') { return getOmniText(locale, 'radar.visitor_trace'); } if (key === 'notes') { return getOmniText(locale, 'crm.follow_up'); } if (key === 'save') { return getOmniText(locale, 'common.save'); } if (key === 'empty') { return getOmniText(locale, 'common.empty'); } if (key === 'error') { return getOmniText(locale, 'common.error'); } if (key === 'loading') { return getOmniText(locale, 'common.loading'); } const zh = isZhLocale(); if (key === 'notesPlaceholder') { return zh ? '追加一条跟进备忘' : 'Add a follow-up note'; } if (key === 'back') { return zh ? '返回列表' : 'Back to list'; } if (key === 'missingId') { return zh ? '缺少潜客编号' : 'Lead ID is missing'; } if (key === 'stay') { return zh ? '停留' : 'Stay'; } 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 === 'applyScriptAction') { return zh ? '填入备忘' : 'Apply to Note'; } if (key === 'scriptCopiedToast') { return zh ? '话术已复制' : 'Script copied'; } if (key === 'savedContactToast') { return zh ? '联系人已保存' : 'Contact saved'; } if (key === 'leadTitle') { return zh ? '潜客档案' : 'Lead Profile'; } if (key === 'aiScriptTitle') { return zh ? '建议跟进话术' : 'Follow-up Suggestion'; } if (key === 'callFailed') { return zh ? '无法拨打电话' : 'Unable to place the call'; } if (key === 'noteSavedToast') { return zh ? '备忘已保存' : 'Note saved'; } if (key === 'notesRequiredToast') { return zh ? '请输入备忘或上传附件' : 'Please enter note or upload attachment'; } if (key === 'saving') { return zh ? '保存中' : 'Saving'; } return ''; } export function statusLabelOf(status: string): string { return statusLabel(status); } export function formatTrackStay(ms: number | null | undefined): string { return formatStay(ms); } export function formatTrackDepth(depth: number | null | undefined): string { return formatDepth(depth); } export function formatTrackPlace(country?: string, city?: string): string { return formatLocation(country, city); } /** * 解析为正整数。非正整数返回 null */ export function parseLeadId( raw: string | number | null | undefined ): string | number | null { if (raw == null) { return null; } if (typeof raw === 'number') { if (raw <= 0) { return null; } if (Math.floor(raw) !== raw) { return null; } return raw; } const text = String(raw).trim(); if (text === '') { return null; } if (!/^[1-9]\d*$/.test(text)) { return null; } return Number(text); } /** * 拼接备忘:新内容追加在旧内容后,空输入不覆盖 */ export function appendNote( existing: string | null | undefined, next: string | null | undefined ): string { const prev = existing != null ? String(existing).trim() : ''; const extra = next != null ? String(next).trim() : ''; if (prev === '') { return extra; } if (extra === '') { return prev; } 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}`; } function toastNone(title: string): void { uni.showToast({ title, icon: 'none', }); } /** * 打开外链:优先 window.open(H5),否则复制链接(对齐 useLogin openOrCopyAuthUrl) */ function openExternalUrl(url: string): boolean { const safeUrl = url != null ? url.trim() : ''; if (safeUrl === '') { return false; } let opened = false; try { if (typeof window !== 'undefined' && typeof window.open === 'function') { window.open(safeUrl, '_blank'); opened = true; } } catch (_e) {} if (opened) { return true; } try { uni.setClipboardData({ data: safeUrl, showToast: true, }); return true; } catch (_e) { return false; } } /** * 有合法电话号时调 uni.makePhoneCall;无号码 toast comingSoon。 * 不伪造成功:fail 回调提示 callFailed。 */ export function callLeadPhone(phone: string | null | undefined): boolean { const trimmed = phone != null ? String(phone).trim() : ''; const digits = trimmed.replace(/[^0-9]/g, ''); if (trimmed === '' || digits === '') { toastNone(resolveLeadDetailText('comingSoon')); return false; } uni.makePhoneCall({ phoneNumber: trimmed, fail: () => { toastNone(resolveLeadDetailText('callFailed')); }, }); return true; } /** * 有 WhatsApp/电话号码时打开 wa.me;无号码 toast comingSoon,不打开空链接。 */ export function openLeadWhatsApp( phoneOrWhatsApp: string | null | undefined ): boolean { const waUrl = formatWhatsAppUrl(phoneOrWhatsApp); if (waUrl === '') { toastNone(resolveLeadDetailText('comingSoon')); return false; } return openExternalUrl(waUrl); } /** * 跟进话术生成器 (基于客户姓名、公司、渠道与意向板块) */ 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 topicPart = keyTopic !== '' ? `【${keyTopic}】等` : ''; return `您好 ${name},看到您此前通过 ${source} 关注了我们的数字名片${topicPart}内容。请问您近期是否有相关的业务合作或定制需求?期待与您交流。`; } const firstWord = name.split(' ')[0]; const companyPart = company !== '' ? ` for ${company}` : ''; return `Hi ${firstWord}, thanks for connecting via ${source} and reviewing our profile. Please let me know if you have any questions or collaboration interests${companyPart}. Look 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}`; } export type ParsedNoteResult = { cleanText: string; imageUrls: string[]; }; /** * 结构化解析历史备忘,分离纯文本说明与附件图片 URL 列表 */ export function parseNoteAttachments( rawNote: string | null | undefined ): ParsedNoteResult { if (rawNote == null || rawNote.trim() === '') { return { cleanText: '', imageUrls: [] }; } const trimmed = rawNote.trim(); const bracketRegex = /\[(.*?)\]/g; const imageUrls: string[] = []; let match: RegExpExecArray | null = null; while ((match = bracketRegex.exec(trimmed)) !== null) { const inside = match[1]; if (inside.includes('http://') || inside.includes('https://')) { const parts = inside.split(/\s+/); for (const part of parts) { const p = part.trim(); if (p.startsWith('http://') || p.startsWith('https://')) { imageUrls.push(p); } } } } const cleanText = trimmed.replace(bracketRegex, '').trim(); return { cleanText, imageUrls }; } export type DeviceContactDraft = { id?: string | number; name: string; company?: string; phone?: string; email?: string; }; /** * 保存到手机系统通讯录 (深度复用通用 RFC 6350 / vCard 引擎) */ export async function saveContactToDevice( lead: DeviceContactDraft | null ): Promise { if (lead == null) { return false; } const card: CardProfile = { id: lead.id, name: lead.name, company: lead.company != null ? lead.company : '', phone: lead.phone != null ? lead.phone : '', email: lead.email != null ? lead.email : '', }; return saveToContacts(card); } function readErrorMessage(err: unknown): string { if (err instanceof Error && err.message !== '') { return err.message; } return resolveLeadDetailText('error'); } function parseTrackList( payload: OwnerPageData | null | undefined ): RadarTrack[] { if (payload == null || payload.list == null) { return []; } return payload.list; } /** * 拉取潜客详情与足迹。可注入 fetchOwnerLeadInfo / fetchOwnerRadarPage */ export async function loadDetail( id: string | number | null | undefined, api?: LoadDetailApi ): Promise { const leadId = parseLeadId(id); if (leadId == null) { return { lead: null, tracks: [], empty: true, errorMessage: '', }; } const fetchInfo = api != null && api.fetchOwnerLeadInfo != null ? api.fetchOwnerLeadInfo : fetchOwnerLeadInfo; const fetchRadar = api != null && api.fetchOwnerRadarPage != null ? api.fetchOwnerRadarPage : fetchOwnerRadarPage; try { const lead = await fetchInfo(leadId); const visitorId = lead.visitorId != null ? String(lead.visitorId) : ''; const radarQuery: OwnerPageQuery = { page: DEFAULT_PAGE, size: DEFAULT_SIZE, keyWord: visitorId, cardId: lead.cardId, }; try { const payload = await fetchRadar(radarQuery); const tracks = parseTrackList(payload); return { lead, tracks, empty: false, errorMessage: '', }; } catch (radarErr) { return { lead, tracks: [], empty: false, errorMessage: readErrorMessage(radarErr), }; } } catch (err) { return { lead: null, tracks: [], empty: true, errorMessage: readErrorMessage(err), }; } } /** * 更新 notes(支持附件结构化嵌入):POST /admin/crm/lead/update { id, notes } * 仅 Cool-Admin code === 1000(http 层已解包成功)视为保存成功 */ export async function saveNote( input: SaveNoteInput, api?: SaveNoteApi ): Promise { const notes = appendStructuredNote( input.notes, input.next, input.attachments ); const post = api != null && api.httpPost != null ? api.httpPost : httpPost; const payload: Record = { id: input.id, notes, }; try { const lead = await post('/admin/crm/lead/update', payload); return { ok: true, code: 1000, lead, errorMessage: '', }; } catch (err) { return { ok: false, code: undefined, lead: null, errorMessage: readErrorMessage(err), }; } } export function createLeadDetailState(): LeadDetailState { return { rawId: '', leadId: null, lead: null, name: '', company: '', phone: '', email: '', source: '', status: '', intentScore: 0, existingNotes: '', nextNote: '', attachments: [], tracks: [], loading: false, saving: false, empty: false, missingId: false, errorMessage: '', aiScript: '', aiGenerating: false, }; } export function applyLeadDetailOptions( state: LeadDetailState, options: LeadDetailPageOptions | null ): boolean { if (options == null || options.id == null) { state.rawId = ''; state.leadId = null; state.missingId = true; state.empty = true; return false; } const parsed = parseLeadId(options.id); state.rawId = String(options.id).trim(); state.leadId = parsed; state.missingId = parsed == null; state.empty = parsed == null; return parsed != null; } function applyLeadToState(state: LeadDetailState, lead: LeadEntity): void { state.lead = lead; state.name = lead.name; state.company = lead.company != null ? lead.company : ''; state.phone = lead.phone != null ? lead.phone : ''; state.email = lead.email != null ? lead.email : ''; state.source = lead.source != null ? String(lead.source) : ''; state.status = lead.status; state.intentScore = lead.intentScore; state.existingNotes = lead.notes != null ? lead.notes : ''; } export function applyLoadResult( state: LeadDetailState, result: LoadDetailResult ): void { if (result.lead != null) { applyLeadToState(state, result.lead); state.empty = false; state.missingId = false; } else { state.lead = null; state.empty = true; } state.tracks = result.tracks; state.errorMessage = result.errorMessage; } export async function refreshLeadDetail( state: LeadDetailState, api?: LoadDetailApi ): Promise { if (state.missingId || state.leadId == null) { const skipped: LoadDetailResult = { lead: null, tracks: [], empty: true, errorMessage: '', }; applyLoadResult(state, skipped); state.missingId = true; return skipped; } if (state.loading) { return { lead: state.lead, tracks: state.tracks, empty: state.empty, errorMessage: state.errorMessage, }; } state.loading = true; state.errorMessage = ''; const result = await loadDetail(state.leadId, api); applyLoadResult(state, result); state.loading = false; return result; } export async function submitLeadNote( state: LeadDetailState, api?: SaveNoteApi ): Promise { if (state.leadId == null || state.saving) { return false; } const next = state.nextNote.trim(); const hasAttachments = state.attachments != null && state.attachments.length > 0; if (next === '' && !hasAttachments) { return false; } state.saving = true; state.errorMessage = ''; const result = await saveNote( { id: state.leadId, notes: state.existingNotes, next, status: state.lead != null ? state.lead.status : undefined, attachments: state.attachments, }, api ); // 仅接口 code === 1000 才清空输入并标成已保存 if (result.ok && result.code === 1000 && result.lead != null) { applyLeadToState(state, result.lead); state.nextNote = ''; state.attachments = []; state.saving = false; return true; } state.saving = false; state.errorMessage = result.errorMessage; return false; } export async function updateLeadStage( state: LeadDetailState, newStatus: LeadStatus, api?: SaveNoteApi ): Promise { 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, fail: () => { uni.switchTab({ url: '/pages/owner/leads', }); }, }); }