/** * OmniCard AI (CRMmp) - 名片主独立数据统计与销售漏斗业务驱动层 * 纯函数计算转化率、漏斗阶段折损、渠道成效矩阵与国际化 * R31: 数字与柱状仅来自接口;失败/缺字段时归零且图表为空 */ import { LeadEntity, OwnerPageData, OwnerPageQuery } from '../../common/types.uts'; import { fetchOwnerLeadPage } from '../../common/api.uts'; import { getCurrentLocale, getOmniText } from '../../common/locale.uts'; export type StatsPeriod = 'today' | '7d' | '30d' | 'all'; export type FunnelStep = { key: string; label: string; count: number; rate: number; }; export type ChannelMetric = { source: 'nfc' | 'qr' | 'link' | 'ai_chat'; label: string; leadsCount: number; wonCount: number; conversionRate: number; }; export type StatsSummary = { totalLeads: number; hotLeads: number; wonLeads: number; overallRate: number; }; export type StatsState = { period: StatsPeriod; summary: StatsSummary; funnel: FunnelStep[]; channels: ChannelMetric[]; loading: boolean; errorMessage: string; }; export type LoadStatsApi = { fetchOwnerLeadPage?: ( query: OwnerPageQuery ) => Promise | Record | null>; }; function isZhLocale(): boolean { return getCurrentLocale() === 'zh-CN'; } export function calcConversionRate(total: number, won: number): number { if (total <= 0 || won <= 0) { return 0; } const rate = (won / total) * 100; return Math.round(rate * 10) / 10; } export function calcFunnelStepRate(currentCount: number, baseCount: number): number { if (baseCount <= 0 || currentCount <= 0) { return 0; } const rate = (currentCount / baseCount) * 100; return Math.round(rate * 10) / 10; } export function resolveStatsText(key: string): string { const locale = getCurrentLocale(); if (key === 'title') { return isZhLocale() ? '经营与漏斗统计' : 'Pipeline & Analytics'; } if (key === 'subtitle') { return isZhLocale() ? '全渠道商机转化与意向大盘' : 'Full-funnel lead insights & radar score'; } if (key === 'empty') { return getOmniText(locale, 'common.empty'); } if (key === 'error') { return getOmniText(locale, 'common.error'); } if (key === 'loading') { return getOmniText(locale, 'common.loading'); } if (key === 'retry') { return getOmniText(locale, 'portal.retry'); } // Periods if (key === 'periodToday') { return isZhLocale() ? '今日' : 'Today'; } if (key === 'period7d') { return isZhLocale() ? '近7天' : '7 Days'; } if (key === 'period30d') { return isZhLocale() ? '近30天' : '30 Days'; } if (key === 'periodAll') { return isZhLocale() ? '全部' : 'All Time'; } // Funnel & Metrics if (key === 'metricTotal') { return isZhLocale() ? '潜客总量' : 'Total Leads'; } if (key === 'metricHot') { return isZhLocale() ? '高意向商机' : 'High Intent'; } if (key === 'metricWon') { return isZhLocale() ? '签约成交' : 'Deals Won'; } if (key === 'metricRate') { return isZhLocale() ? '综合转化率' : 'Conversion'; } if (key === 'funnelTitle') { return isZhLocale() ? '商机转化漏斗' : 'Pipeline Funnel'; } if (key === 'channelTitle') { return isZhLocale() ? '获客渠道成效' : 'Acquisition Channels'; } if (key === 'stepNew') { return isZhLocale() ? '新建线索' : 'New Leads'; } if (key === 'stepHot') { return isZhLocale() ? '高意向' : 'Hot Intent'; } if (key === 'stepContacted') { return isZhLocale() ? '已接洽' : 'Contacted'; } if (key === 'stepWon') { return isZhLocale() ? '赢单成单' : 'Deals Won'; } if (key === 'sourceNfc') { return isZhLocale() ? 'NFC 贴卡' : 'NFC Touch'; } if (key === 'sourceQr') { return isZhLocale() ? '海报码' : 'Poster QR'; } if (key === 'sourceLink') { return isZhLocale() ? '直链分享' : 'Direct Link'; } if (key === 'sourceAi') { return isZhLocale() ? 'AI顾问' : 'AI Concierge'; } if (key === 'liveBadge') { return isZhLocale() ? '实时大盘' : 'LIVE INSIGHTS'; } if (key === 'healthTitle') { return isZhLocale() ? '潜客意向度健康度' : 'Intent Distribution'; } if (key === 'tierHot') { return isZhLocale() ? '核心高意向 (80+)' : 'High Intent (80+)'; } if (key === 'tierWarm') { return isZhLocale() ? '重点跟进 (50-79)' : 'Warm Leads (50-79)'; } if (key === 'tierCool') { return isZhLocale() ? '待培育池 (<50)' : 'Cool Pool (<50)'; } if (key === 'stepDescNew') { return isZhLocale() ? '建档接入' : 'Captured'; } if (key === 'stepDescHot') { return isZhLocale() ? '高频互动' : 'Engaged'; } if (key === 'stepDescContacted') { return isZhLocale() ? '方案沟通' : 'Pitched'; } if (key === 'stepDescWon') { return isZhLocale() ? '签约成交' : 'Closed'; } if (key === 'badgeTotal') { return isZhLocale() ? '总量' : 'TOTAL'; } if (key === 'badgeHot') { return isZhLocale() ? '高意向' : 'HOT'; } if (key === 'badgeWon') { return isZhLocale() ? '成交' : 'WON'; } if (key === 'badgeRate') { return isZhLocale() ? '转化' : 'CVR'; } if (key === 'funnelStages') { return isZhLocale() ? '四阶' : '4 STAGES'; } return key; } function emptySummary(): StatsSummary { return { totalLeads: 0, hotLeads: 0, wonLeads: 0, overallRate: 0, }; } export function createStatsState(): StatsState { return { period: 'all', summary: emptySummary(), funnel: [], channels: [], loading: false, errorMessage: '', }; } /** R31: 失败或缺字段时数字归零、图表清空 */ export function resetStatsToEmpty(state: StatsState): void { state.summary = emptySummary(); state.funnel = []; state.channels = []; } function parseLeadList(res: OwnerPageData | Record | null): LeadEntity[] { if (res == null) { return []; } const list = (res as OwnerPageData).list; if (list == null || !Array.isArray(list)) { return []; } return list; } export function computeStatsFromLeads(leads: LeadEntity[], period: StatsPeriod): StatsState { const state = createStatsState(); state.period = period; if (leads == null || leads.length === 0) { return state; } let totalCount = 0; let hotCount = 0; let newCount = 0; let contactedCount = 0; let wonCount = 0; const channelMap: Record = { nfc: { leads: 0, won: 0 }, qr: { leads: 0, won: 0 }, link: { leads: 0, won: 0 }, ai_chat: { leads: 0, won: 0 }, }; for (let i = 0; i < leads.length; i++) { const item = leads[i]; totalCount++; const score = item.intentScore != null ? item.intentScore : 0; if (item.status === 'hot' || score >= 80) { hotCount++; } if (item.status === 'new') { newCount++; } else if (item.status === 'contacted') { contactedCount++; } else if (item.status === 'won') { wonCount++; } const src = item.source != null ? item.source : 'link'; if (channelMap[src] != null) { channelMap[src].leads++; if (item.status === 'won') { channelMap[src].won++; } } } state.summary.totalLeads = totalCount; state.summary.hotLeads = hotCount; state.summary.wonLeads = wonCount; state.summary.overallRate = calcConversionRate(totalCount, wonCount); state.funnel = [ { key: 'new', label: resolveStatsText('stepNew'), count: totalCount, rate: totalCount > 0 ? 100 : 0, }, { key: 'hot', label: resolveStatsText('stepHot'), count: hotCount, rate: calcFunnelStepRate(hotCount, totalCount), }, { key: 'contacted', label: resolveStatsText('stepContacted'), count: contactedCount, rate: calcFunnelStepRate(contactedCount, totalCount), }, { key: 'won', label: resolveStatsText('stepWon'), count: wonCount, rate: calcFunnelStepRate(wonCount, totalCount), }, ]; state.channels = [ { source: 'nfc', label: resolveStatsText('sourceNfc'), leadsCount: channelMap['nfc'].leads, wonCount: channelMap['nfc'].won, conversionRate: calcConversionRate(channelMap['nfc'].leads, channelMap['nfc'].won), }, { source: 'qr', label: resolveStatsText('sourceQr'), leadsCount: channelMap['qr'].leads, wonCount: channelMap['qr'].won, conversionRate: calcConversionRate(channelMap['qr'].leads, channelMap['qr'].won), }, { source: 'link', label: resolveStatsText('sourceLink'), leadsCount: channelMap['link'].leads, wonCount: channelMap['link'].won, conversionRate: calcConversionRate(channelMap['link'].leads, channelMap['link'].won), }, { source: 'ai_chat', label: resolveStatsText('sourceAi'), leadsCount: channelMap['ai_chat'].leads, wonCount: channelMap['ai_chat'].won, conversionRate: calcConversionRate(channelMap['ai_chat'].leads, channelMap['ai_chat'].won), }, ]; return state; } export async function loadStatsData(state: StatsState, api?: LoadStatsApi): Promise { state.loading = true; state.errorMessage = ''; const fetchPage = api != null && api.fetchOwnerLeadPage != null ? api.fetchOwnerLeadPage : fetchOwnerLeadPage; try { const query: OwnerPageQuery = { page: 1, size: 100, }; const res = await fetchPage(query); const list = parseLeadList(res); const computed = computeStatsFromLeads(list, state.period); state.summary = computed.summary; state.funnel = computed.funnel; state.channels = computed.channels; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); resetStatsToEmpty(state); state.errorMessage = msg !== '' ? msg : resolveStatsText('error'); } finally { state.loading = false; } }