From 4653a5639286b30df63aa879fff2e56cc18d1755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Tue, 29 Sep 2026 11:32:59 +0800 Subject: [PATCH] feat: show intent rate window summaries --- .../PerformanceConversionDetail.test.tsx | 22 +++++++ .../PerformanceConversionDetail.tsx | 31 ++++++++++ .../performance-conversion.test.ts | 62 +++++++++++++++++++ .../performance-conversion.ts | 36 +++++++++++ 4 files changed, 151 insertions(+) diff --git a/components/department-dashboard/PerformanceConversionDetail.test.tsx b/components/department-dashboard/PerformanceConversionDetail.test.tsx index 2db4629..83e6cfe 100644 --- a/components/department-dashboard/PerformanceConversionDetail.test.tsx +++ b/components/department-dashboard/PerformanceConversionDetail.test.tsx @@ -108,6 +108,7 @@ describe('PerformanceConversionDetail', () => { expect(screen.getByText(/经营数据更新至/)).toBeInTheDocument() expect(screen.getAllByText('12.50%').length).toBeGreaterThan(0) expect(screen.getAllByText('¥1,299.50').length).toBeGreaterThan(0) + expect(screen.getAllByText('——').length).toBe(3) expect(screen.getByRole('img', { name: /支付金额从/ })).toBeInTheDocument() expect(screen.getByRole('table')).toHaveTextContent('2026-07-13') }) @@ -148,4 +149,25 @@ describe('PerformanceConversionDetail', () => { await waitFor(() => expect(screen.getByRole('heading', { name: '亿家达折叠书桌' })).toBeInTheDocument()) expect(fetchMock).toHaveBeenCalledTimes(2) }) + + it('shows complete intent-rate averages and change when 15 days are available', async () => { + const daily = Array.from({ length: 15 }, (_, index) => ({ + ...detailData.daily[0], + date: `2026-07-${String(index + 1).padStart(2, '0')}`, + favorites: index < 8 ? 10 : 20, + cartUsers: 10, + visitors: 100, + paymentConversionRate: 0.03, + })) + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: { ...detailData, daily } }), + } as Response) + + render() + + expect(await screen.findByText('23.00%')).toBeInTheDocument() + expect(screen.getByText('27.67%')).toBeInTheDocument() + expect(screen.getByText('20.29%')).toBeInTheDocument() + }) }) diff --git a/components/department-dashboard/PerformanceConversionDetail.tsx b/components/department-dashboard/PerformanceConversionDetail.tsx index 36bddc9..79cab30 100644 --- a/components/department-dashboard/PerformanceConversionDetail.tsx +++ b/components/department-dashboard/PerformanceConversionDetail.tsx @@ -11,6 +11,7 @@ import { import DepartmentShell from './DepartmentShell' import { PERFORMANCE_CONVERSION_METRICS, + buildIntentRateWindowSummary, type PerformanceConversionDailyMetric, type PerformanceConversionMetricKey, type PerformanceConversionTimeseries, @@ -138,6 +139,35 @@ function MetricSummary({ data }: { data: PerformanceConversionTimeseries }) { ) } +function IntentRateSummary({ daily }: { daily: PerformanceConversionDailyMetric[] }) { + const summary = buildIntentRateWindowSummary(daily) + const formatWindowValue = (value: number | null) => value === null ? '——' : `${(value * 100).toFixed(2)}%` + + return ( +
+
+

流程完结后 7日 / 15日平均值

+

从流程完结日开始计算,窗口内每天数据完整时显示,缺少天数或字段显示“——”

+
+
+
+
完结后7日意向率平均
+
{formatWindowValue(summary.sevenDayIntentRate)}
+
+
+
完结后15日意向率平均
+
{formatWindowValue(summary.fifteenDayIntentRate)}
+
+
+
意向率环比(15日 / 7日)
+
{formatWindowValue(summary.intentRateChange)}
+
+
+

意向率 = (收藏人数 + 加购人数)÷ 商品访客数 × 100% + 支付转化率

+
+ ) +} + function DetailContent({ data }: { data: PerformanceConversionTimeseries }) { const [metricKey, setMetricKey] = useState('paymentAmount') const [detailGroup, setDetailGroup] = useState<'流量' | '兴趣' | '成交'>('流量') @@ -172,6 +202,7 @@ function DetailContent({ data }: { data: PerformanceConversionTimeseries }) { + {!hasDailyData ? (
diff --git a/components/department-dashboard/performance-conversion.test.ts b/components/department-dashboard/performance-conversion.test.ts index e3b54d9..95aa98d 100644 --- a/components/department-dashboard/performance-conversion.test.ts +++ b/components/department-dashboard/performance-conversion.test.ts @@ -1,6 +1,8 @@ import { BUSINESS_TREND_BRANDS, addCalendarMonths, + buildIntentRateWindowSummary, + calculateIntentRate, daysBetweenInclusive, extractValidProductIds, isValidDateKey, @@ -35,4 +37,64 @@ describe('performance conversion helpers', () => { expect(isValidDateKey('2026-02-29')).toBe(false) expect(isValidDateKey('2026-9-17')).toBe(false) }) + + it('calculates intent rate from daily interest and conversion metrics', () => { + expect(calculateIntentRate({ favorites: 10, cartUsers: 5, visitors: 100, paymentConversionRate: 0.03 })).toBe(0.18) + expect(calculateIntentRate({ favorites: 10, cartUsers: 5, visitors: 0, paymentConversionRate: 0.03 })).toBeNull() + expect(calculateIntentRate({ favorites: 10, cartUsers: null, visitors: 100, paymentConversionRate: 0.03 })).toBeNull() + }) + + it('calculates complete 7-day and 15-day averages with intent-rate change', () => { + const daily = Array.from({ length: 15 }, (_, index) => ({ + date: `2026-07-${String(index + 1).padStart(2, '0')}`, + impressions: 1000, + visitors: 100, + clickRate: 0.1, + favorites: index < 8 ? 10 : 20, + cartUsers: 10, + paymentConversionRate: 0.03, + paymentAmount: 100, + roi: 1, + })) + + const summary = buildIntentRateWindowSummary(daily) + expect(summary.sevenDayIntentRate).toBeCloseTo(0.23, 12) + expect(summary.fifteenDayIntentRate).toBeCloseTo(0.27666666666666667, 12) + expect(summary.intentRateChange).toBeCloseTo(0.20289855072463767, 12) + }) + + it('returns unavailable values when a window is incomplete or its 7-day rate is zero', () => { + const fiveDays = Array.from({ length: 5 }, (_, index) => ({ + date: `2026-07-${String(index + 1).padStart(2, '0')}`, + impressions: 1000, + visitors: 100, + clickRate: 0.1, + favorites: 10, + cartUsers: 10, + paymentConversionRate: 0.03, + paymentAmount: 100, + roi: 1, + })) + expect(buildIntentRateWindowSummary(fiveDays)).toEqual({ + sevenDayIntentRate: null, + fifteenDayIntentRate: null, + intentRateChange: null, + }) + + const zeroSevenDayRate = Array.from({ length: 15 }, (_, index) => ({ + date: `2026-07-${String(index + 1).padStart(2, '0')}`, + impressions: 1000, + visitors: 100, + clickRate: 0.1, + favorites: index < 7 ? 0 : 10, + cartUsers: index < 7 ? 0 : 10, + paymentConversionRate: index < 7 ? 0 : 0.03, + paymentAmount: 100, + roi: 1, + })) + expect(buildIntentRateWindowSummary(zeroSevenDayRate)).toMatchObject({ + sevenDayIntentRate: 0, + intentRateChange: null, + }) + }) }) diff --git a/components/department-dashboard/performance-conversion.ts b/components/department-dashboard/performance-conversion.ts index fb25f60..94abe51 100644 --- a/components/department-dashboard/performance-conversion.ts +++ b/components/department-dashboard/performance-conversion.ts @@ -66,6 +66,12 @@ export type PerformanceConversionDailyMetric = { roi: number | null } +export type PerformanceConversionWindowSummary = { + sevenDayIntentRate: number | null + fifteenDayIntentRate: number | null + intentRateChange: number | null +} + export type PerformanceConversionTimeseries = { workflow: PerformanceConversionWorkflowItem product: { @@ -87,6 +93,36 @@ export type PerformanceConversionTimeseries = { } } +export function calculateIntentRate(day: Pick): number | null { + const { favorites, cartUsers, visitors, paymentConversionRate } = day + if (favorites === null || cartUsers === null || visitors === null || paymentConversionRate === null + || ![favorites, cartUsers, visitors, paymentConversionRate].every((value) => Number.isFinite(value))) { + return null + } + if (visitors <= 0) return null + return (favorites + cartUsers) / visitors + paymentConversionRate +} + +function averageIntentRate(daily: PerformanceConversionDailyMetric[], windowSize: number) { + const window = daily.slice(0, windowSize) + if (window.length !== windowSize) return null + const values = window.map(calculateIntentRate) + if (values.some((value) => value === null)) return null + return values.reduce((total, value) => total + (value ?? 0), 0) / windowSize +} + +export function buildIntentRateWindowSummary(daily: PerformanceConversionDailyMetric[]): PerformanceConversionWindowSummary { + const sevenDayIntentRate = averageIntentRate(daily, 7) + const fifteenDayIntentRate = averageIntentRate(daily, 15) + const intentRateChange = sevenDayIntentRate !== null + && sevenDayIntentRate !== 0 + && fifteenDayIntentRate !== null + ? (fifteenDayIntentRate - sevenDayIntentRate) / sevenDayIntentRate + : null + + return { sevenDayIntentRate, fifteenDayIntentRate, intentRateChange } +} + export function extractValidProductIds(...values: unknown[]): string[] { const candidates = values.flatMap((value) => String(value || '') .split(/[\s,,;;/、|]+/)