From 78a2f8815866ca2c7d73da6dd8a1608d26c9b1a8 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Mon, 21 Sep 2026 14:52:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(crm):=20=E6=96=B0=E5=A2=9E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=BD=9C=E5=AE=A2=E7=BB=8F=E8=90=A5=E4=B8=8E=E6=BC=8F?= =?UTF-8?q?=E6=96=97=E7=BB=9F=E8=AE=A1=E4=B8=AD=E5=BF=83(pages/owner/stats?= =?UTF-8?q?.uvue)=EF=BC=8C=E8=A7=A3=E8=80=A6=E9=AB=98=E9=A2=91=E6=BD=9C?= =?UTF-8?q?=E5=AE=A2=E7=AE=A1=E7=90=86=E5=B9=B6=E6=94=AF=E6=8C=81=E5=85=A8?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E8=BD=AC=E5=8C=96=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/test/mpuniapp/owner-stats.test.ts | 189 +++++++ mpuniapp/pages.json | 7 + mpuniapp/pages/owner/leads.uvue | 35 ++ mpuniapp/pages/owner/stats.uvue | 646 ++++++++++++++++++++++ mpuniapp/pages/owner/useStats.uts | 270 +++++++++ 5 files changed, 1147 insertions(+) create mode 100644 backend/test/mpuniapp/owner-stats.test.ts create mode 100644 mpuniapp/pages/owner/stats.uvue create mode 100644 mpuniapp/pages/owner/useStats.uts diff --git a/backend/test/mpuniapp/owner-stats.test.ts b/backend/test/mpuniapp/owner-stats.test.ts new file mode 100644 index 0000000..535e423 --- /dev/null +++ b/backend/test/mpuniapp/owner-stats.test.ts @@ -0,0 +1,189 @@ +/** + * OmniCard AI (CRMmp) - 名片主独立数据统计与漏斗分析页测试套件 (TDD Red -> Green) + * + * 验证独立统计页面的数据计算、漏斗转化分析、渠道矩阵与界面规范 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const STATS_PAGE = path.resolve( + __dirname, + '../../../mpuniapp/pages/owner/stats.uvue' +); +const STATS_UTS = path.resolve( + __dirname, + '../../../mpuniapp/pages/owner/useStats.uts' +); +const PAGES_JSON = path.resolve( + __dirname, + '../../../mpuniapp/pages.json' +); + +type StatsPeriod = 'today' | '7d' | '30d' | 'all'; + +type LeadEntity = { + id: string | number; + cardId: string | number; + name: string; + company?: string; + status: 'new' | 'contacted' | 'hot' | 'won' | 'archived'; + intentScore: number; + source: 'nfc' | 'qr' | 'link' | 'ai_chat'; +}; + +type FunnelStep = { + key: string; + label: string; + count: number; + rate: number; +}; + +type ChannelMetric = { + source: 'nfc' | 'qr' | 'link' | 'ai_chat'; + label: string; + leadsCount: number; + wonCount: number; + conversionRate: number; +}; + +type StatsSummary = { + totalLeads: number; + hotLeads: number; + wonLeads: number; + overallRate: number; +}; + +type StatsState = { + period: StatsPeriod; + summary: StatsSummary; + funnel: FunnelStep[]; + channels: ChannelMetric[]; + loading: boolean; + errorMessage: string; +}; + +type UseStatsModule = { + createStatsState: () => StatsState; + calcConversionRate: (total: number, won: number) => number; + calcFunnelStepRate: (currentCount: number, baseCount: number) => number; + computeStatsFromLeads: (leads: LeadEntity[], period: StatsPeriod) => StatsState; + resolveStatsText: (key: string) => string; +}; + +const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; + +function loadUseStats(): UseStatsModule { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require(STATS_UTS) as UseStatsModule; +} + +describe('OmniCard AI 名片主独立统计与漏斗业务层 (useStats.uts)', () => { + beforeEach(() => { + jest.resetModules(); + (global as { uni?: Record }).uni = { + getLocale: () => 'zh-CN', + showToast: jest.fn(), + navigateTo: jest.fn(), + }; + }); + + afterEach(() => { + delete (global as { uni?: Record }).uni; + }); + + describe('1. 漏斗与转化率纯函数计算', () => { + it('1.1 calcConversionRate 在总数为0时返回0,正常时保留一位小数', () => { + const { calcConversionRate } = loadUseStats(); + expect(calcConversionRate(0, 0)).toBe(0); + expect(calcConversionRate(0, 5)).toBe(0); + expect(calcConversionRate(10, 3)).toBe(30); + expect(calcConversionRate(3, 1)).toBe(33.3); + expect(calcConversionRate(7, 2)).toBe(28.6); + }); + + it('1.2 calcFunnelStepRate 计算阶梯相对率', () => { + const { calcFunnelStepRate } = loadUseStats(); + expect(calcFunnelStepRate(0, 100)).toBe(0); + expect(calcFunnelStepRate(50, 100)).toBe(50); + expect(calcFunnelStepRate(80, 0)).toBe(0); + }); + }); + + describe('2. computeStatsFromLeads 多维度聚合', () => { + it('2.1 从潜客样本计算出总数、高意向、成交数与漏斗4阶梯', () => { + const { computeStatsFromLeads } = loadUseStats(); + const mockLeads: LeadEntity[] = [ + { id: 1, cardId: 1, name: 'Lead 1', status: 'new', intentScore: 40, source: 'nfc' }, + { id: 2, cardId: 1, name: 'Lead 2', status: 'hot', intentScore: 85, source: 'qr' }, + { id: 3, cardId: 1, name: 'Lead 3', status: 'contacted', intentScore: 70, source: 'link' }, + { id: 4, cardId: 1, name: 'Lead 4', status: 'won', intentScore: 92, source: 'ai_chat' }, + ]; + + const state = computeStatsFromLeads(mockLeads, 'all'); + + expect(state.summary.totalLeads).toBe(4); + expect(state.summary.wonLeads).toBe(1); + expect(state.summary.hotLeads).toBe(2); // intentScore >= 80 (Lead 2, Lead 4) or status === 'hot' + expect(state.summary.overallRate).toBe(25); + + // 漏斗必须包含 new, hot, contacted, won 四个阶梯 + expect(state.funnel).toHaveLength(4); + expect(state.funnel[0].key).toBe('new'); + expect(state.funnel[3].key).toBe('won'); + + // 渠道分析必须覆盖 4 种渠道 + expect(state.channels).toHaveLength(4); + const aiChannel = state.channels.find(c => c.source === 'ai_chat'); + expect(aiChannel).toBeDefined(); + expect(aiChannel?.leadsCount).toBe(1); + expect(aiChannel?.wonCount).toBe(1); + expect(aiChannel?.conversionRate).toBe(100); + }); + }); +}); + +describe('OmniCard AI 独立统计视图契约 (stats.uvue & pages.json)', () => { + it('1. pages.json 中已成功注册 pages/owner/stats 页面路由', () => { + expect(fs.existsSync(PAGES_JSON)).toBe(true); + const content = fs.readFileSync(PAGES_JSON, 'utf-8'); + const cleanJson = content.replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '$1'); + const config = JSON.parse(cleanJson); + + const hasRoute = config.pages.some( + (p: { path: string }) => p.path === 'pages/owner/stats' + ); + expect(hasRoute).toBe(true); + }); + + it('2. stats.uvue 包含完整的便当盒统计组件与设计 Token', () => { + expect(fs.existsSync(STATS_PAGE)).toBe(true); + expect(fs.existsSync(STATS_UTS)).toBe(true); + + const pageSource = fs.readFileSync(STATS_PAGE, 'utf8'); + const logicSource = fs.readFileSync(STATS_UTS, 'utf8'); + + expect(pageSource.includes('script setup lang="uts"')).toBe(true); + expect(pageSource.includes('#0B5CFF')).toBe(true); + expect(pageSource.includes('#F7F8FA')).toBe(true); + expect(pageSource.includes('#101828')).toBe(true); + expect(pageSource.includes('#667085')).toBe(true); + expect(pageSource.includes('#E3E7ED')).toBe(true); + expect(pageSource.includes('period-chip')).toBe(true); + expect(pageSource.includes('stats-funnel')).toBe(true); + expect(pageSource.includes('channel-grid')).toBe(true); + expect(logicSource.includes('resolveStatsText')).toBe(true); + }); + + it('3. stats.uvue 无 emoji、无 any、模板无硬编码中文', () => { + const pageSource = fs.readFileSync(STATS_PAGE, 'utf8'); + const logicSource = fs.readFileSync(STATS_UTS, 'utf8'); + const template = pageSource.split(' {{ hotUnreadText }} + + {{ statsLabel }} + {{ refreshText }} @@ -285,6 +288,10 @@ const hotUnreadText = computed((): string => { return formatHotUnread(state.hotUnread); }); +const statsLabel = computed((): string => { + return getCurrentLocale() === 'zh-CN' ? '统计' : 'Stats'; +}); + const leadsCountSummary = computed((): string => { const count = state.total > 0 ? state.total : state.list.length; return `${count}`; @@ -423,6 +430,12 @@ function openRadar(): void { openRadarPage(); } +function openStats(): void { + uni.navigateTo({ + url: '/pages/owner/stats', + }); +} + async function handleSearch(): Promise { await refreshLeads(state); } @@ -541,6 +554,28 @@ onShow(() => { color: var(--oc-error, #F04438); } +.header-stats-btn { + min-height: 32px; + padding-inline: 10px; + background-color: var(--oc-primary-soft, #EBF5FF); + border: 1px solid #B2DDFF; + border-radius: var(--oc-radius-btn, 14px); + display: flex; + align-items: center; + justify-content: center; + transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.header-stats-btn:active { + opacity: 0.85; +} + +.header-stats-text { + font-size: 12px; + color: var(--oc-primary, #0B5CFF); + font-weight: 600; +} + .header-refresh-btn { min-height: 32px; padding-inline: 10px; diff --git a/mpuniapp/pages/owner/stats.uvue b/mpuniapp/pages/owner/stats.uvue new file mode 100644 index 0000000..01ca465 --- /dev/null +++ b/mpuniapp/pages/owner/stats.uvue @@ -0,0 +1,646 @@ + + + + + diff --git a/mpuniapp/pages/owner/useStats.uts b/mpuniapp/pages/owner/useStats.uts new file mode 100644 index 0000000..0d2a618 --- /dev/null +++ b/mpuniapp/pages/owner/useStats.uts @@ -0,0 +1,270 @@ +/** + * OmniCard AI (CRMmp) - 名片主独立数据统计与销售漏斗业务驱动层 + * 纯函数计算转化率、漏斗阶段折损、渠道成效矩阵与国际化 + */ + +import { LeadEntity, 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; +}; + +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'; + } + + return key; +} + +export function createStatsState(): StatsState { + return { + period: 'all', + summary: { + totalLeads: 0, + hotLeads: 0, + wonLeads: 0, + overallRate: 0, + }, + funnel: [ + { key: 'new', label: resolveStatsText('stepNew'), count: 0, rate: 0 }, + { key: 'hot', label: resolveStatsText('stepHot'), count: 0, rate: 0 }, + { key: 'contacted', label: resolveStatsText('stepContacted'), count: 0, rate: 0 }, + { key: 'won', label: resolveStatsText('stepWon'), count: 0, rate: 0 }, + ], + channels: [ + { source: 'nfc', label: resolveStatsText('sourceNfc'), leadsCount: 0, wonCount: 0, conversionRate: 0 }, + { source: 'qr', label: resolveStatsText('sourceQr'), leadsCount: 0, wonCount: 0, conversionRate: 0 }, + { source: 'link', label: resolveStatsText('sourceLink'), leadsCount: 0, wonCount: 0, conversionRate: 0 }, + { source: 'ai_chat', label: resolveStatsText('sourceAi'), leadsCount: 0, wonCount: 0, conversionRate: 0 }, + ], + loading: false, + errorMessage: '', + }; +} + +export function computeStatsFromLeads(leads: LeadEntity[], period: StatsPeriod): StatsState { + const state = createStatsState(); + state.period = period; + + 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++; + + if (item.status === 'hot' || item.intentScore >= 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[0].count = totalCount; + state.funnel[0].rate = totalCount > 0 ? 100 : 0; + + state.funnel[1].count = hotCount; + state.funnel[1].rate = calcFunnelStepRate(hotCount, totalCount); + + state.funnel[2].count = contactedCount; + state.funnel[2].rate = calcFunnelStepRate(contactedCount, totalCount); + + state.funnel[3].count = wonCount; + state.funnel[3].rate = calcFunnelStepRate(wonCount, totalCount); + + // 渠道成效 + state.channels[0].leadsCount = channelMap['nfc'].leads; + state.channels[0].wonCount = channelMap['nfc'].won; + state.channels[0].conversionRate = calcConversionRate(channelMap['nfc'].leads, channelMap['nfc'].won); + + state.channels[1].leadsCount = channelMap['qr'].leads; + state.channels[1].wonCount = channelMap['qr'].won; + state.channels[1].conversionRate = calcConversionRate(channelMap['qr'].leads, channelMap['qr'].won); + + state.channels[2].leadsCount = channelMap['link'].leads; + state.channels[2].wonCount = channelMap['link'].won; + state.channels[2].conversionRate = calcConversionRate(channelMap['link'].leads, channelMap['link'].won); + + state.channels[3].leadsCount = channelMap['ai_chat'].leads; + state.channels[3].wonCount = channelMap['ai_chat'].won; + state.channels[3].conversionRate = calcConversionRate(channelMap['ai_chat'].leads, channelMap['ai_chat'].won); + + return state; +} + +export async function loadStatsData(state: StatsState): Promise { + state.loading = true; + state.errorMessage = ''; + + try { + const query: OwnerPageQuery = { + page: 1, + size: 100, + }; + const res = await fetchOwnerLeadPage(query); + const computed = computeStatsFromLeads(res.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); + state.errorMessage = msg !== '' ? msg : resolveStatsText('error'); + } finally { + state.loading = false; + } +}