补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
373 行
9.8 KiB
Plaintext
373 行
9.8 KiB
Plaintext
/**
|
|
* 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<OwnerPageData<LeadEntity> | Record<string, unknown> | 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<LeadEntity> | Record<string, unknown> | null): LeadEntity[] {
|
|
if (res == null) {
|
|
return [];
|
|
}
|
|
const list = (res as OwnerPageData<LeadEntity>).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<string, { leads: number; won: number }> = {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|