feat: 补齐数据大盘京东 CPS 与金币展示 (#29)
改了什么:数据大盘总收入/CPS 收入纳入京东 CPS;京东 CPS 卡片展示有效/无效订单;刷新按钮改为同步全部 CPS 订单;ARPU 展示产品确认的昨日活跃用户分母;激励视频金币展示真实值并标注已计入领券奖励。 验证方式:npx tsc --noEmit;浏览器验证 /dashboard 已显示京东 CPS、ARPU 分母和激励视频金币。 --------- Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: #29 Co-authored-by: chenshuobo <chenshuobo@wonderable.ai> Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
This commit was merged in pull request #29.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Select, Spin, Tag, Tooltip, message } from 'antd';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Alert, App, Select, Spin, Tag, Tooltip } from 'antd';
|
||||
import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -10,6 +10,8 @@ import type { AdRevenueReport, DashboardOverview } from '@/lib/types';
|
||||
type PeriodKey = 'yesterday' | '7d' | '30d';
|
||||
type TrendSeriesKey = 'dau' | 'newUsers' | 'comparisons';
|
||||
type DashboardTrendPoint = DashboardOverview['period']['trend'][number];
|
||||
type DeltaTone = 'muted' | 'up' | 'down' | 'flat';
|
||||
type DeltaInfo = { value: ReactNode; tone: DeltaTone };
|
||||
|
||||
const PERIODS: { key: PeriodKey; label: string; days: number }[] = [
|
||||
{ key: 'yesterday', label: '昨日', days: 1 },
|
||||
@@ -35,6 +37,72 @@ const fmtYuan = (yuan: number | null | undefined) => (yuan == null ? '--' : `¥$
|
||||
const fmtMsAsSeconds = (ms: number | null | undefined) => (ms == null ? '--' : (ms / 1000).toFixed(1));
|
||||
const pct = (v: number | null | undefined) => (v == null ? '--' : `${(v * 100).toFixed(1)}%`);
|
||||
const compact = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v));
|
||||
const fmtCoinAmount = (v: number | null | undefined) => {
|
||||
if (v == null) return '--';
|
||||
if (Math.abs(v) >= 10000) return `${(v / 10000).toFixed(2).replace(/\.?0+$/, '')} 万`;
|
||||
return numberFmt.format(v);
|
||||
};
|
||||
const CMP_LABEL = 'vs 上周期';
|
||||
|
||||
function withCompare(main: string) {
|
||||
return (
|
||||
<>
|
||||
{main}
|
||||
<span className="cmp">{CMP_LABEL}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function percentDelta(current: number | null | undefined, previous: number | null | undefined): DeltaInfo {
|
||||
if (current == null || previous == null) return { value: '暂无上期', tone: 'flat' };
|
||||
if (previous === 0) {
|
||||
if (current === 0) return { value: withCompare('持平'), tone: 'flat' };
|
||||
return { value: '上期为 0', tone: current > 0 ? 'up' : 'down' };
|
||||
}
|
||||
const diff = (current - previous) / Math.abs(previous);
|
||||
if (Math.abs(diff) < 0.0005) return { value: withCompare('持平'), tone: 'flat' };
|
||||
const arrow = diff > 0 ? '▲' : '▼';
|
||||
return {
|
||||
value: withCompare(`${arrow} ${(Math.abs(diff) * 100).toFixed(1)}%`),
|
||||
tone: diff > 0 ? 'up' : 'down',
|
||||
};
|
||||
}
|
||||
|
||||
function pointDelta(current: number | null | undefined, previous: number | null | undefined): DeltaInfo {
|
||||
if (current == null || previous == null) return { value: '暂无上期', tone: 'flat' };
|
||||
const diff = (current - previous) * 100;
|
||||
if (Math.abs(diff) < 0.05) return { value: withCompare('持平'), tone: 'flat' };
|
||||
const arrow = diff > 0 ? '▲' : '▼';
|
||||
return {
|
||||
value: withCompare(`${arrow} ${Math.abs(diff).toFixed(1)}pt`),
|
||||
tone: diff > 0 ? 'up' : 'down',
|
||||
};
|
||||
}
|
||||
|
||||
function durationDelta(currentMs: number | null | undefined, previousMs: number | null | undefined): DeltaInfo {
|
||||
if (currentMs == null || previousMs == null) return { value: '暂无上期', tone: 'flat' };
|
||||
const diffSeconds = (currentMs - previousMs) / 1000;
|
||||
if (Math.abs(diffSeconds) < 0.05) return { value: withCompare('持平'), tone: 'flat' };
|
||||
return diffSeconds < 0
|
||||
? { value: `▼ ${Math.abs(diffSeconds).toFixed(1)}s 更快`, tone: 'up' }
|
||||
: { value: `▲ ${Math.abs(diffSeconds).toFixed(1)}s 更慢`, tone: 'down' };
|
||||
}
|
||||
|
||||
function moneyDeltaCents(current: number | null | undefined, previous: number | null | undefined): DeltaInfo {
|
||||
if (current == null || previous == null) return { value: '暂无上期', tone: 'flat' };
|
||||
const diff = current - previous;
|
||||
if (Math.abs(diff) < 0.5) return { value: withCompare('持平'), tone: 'flat' };
|
||||
const arrow = diff > 0 ? '▲' : '▼';
|
||||
return {
|
||||
value: withCompare(`${arrow} ${fmtCents(Math.abs(diff))}`),
|
||||
tone: diff > 0 ? 'up' : 'down',
|
||||
};
|
||||
}
|
||||
|
||||
function ratioBadge(value: number | null | undefined, total: number | null | undefined): DeltaInfo {
|
||||
if (value == null || total == null || total <= 0) return { value: '占 --', tone: 'flat' };
|
||||
return { value: `占 ${((value / total) * 100).toFixed(1)}%`, tone: 'flat' };
|
||||
}
|
||||
|
||||
function periodRange(period: PeriodKey) {
|
||||
const p = PERIODS.find((it) => it.key === period) ?? PERIODS[0];
|
||||
@@ -43,6 +111,12 @@ function periodRange(period: PeriodKey) {
|
||||
return { start, end, label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}`, days: p.days };
|
||||
}
|
||||
|
||||
function previousPeriodRange(range: ReturnType<typeof periodRange>) {
|
||||
const end = range.start.subtract(1, 'day');
|
||||
const start = end.subtract(range.days - 1, 'day');
|
||||
return { start, end, label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}` };
|
||||
}
|
||||
|
||||
function retentionTitle(period: PeriodKey) {
|
||||
if (period === '7d') return '近 7 日新增留存';
|
||||
if (period === '30d') return '近 30 日新增留存';
|
||||
@@ -92,6 +166,16 @@ function sceneAdEcpm(summary: ReturnType<typeof adSceneSummary>) {
|
||||
return fmtYuan((summary.revenue_yuan / summary.impressions) * 1000);
|
||||
}
|
||||
|
||||
function cpsCommissionCents(data: DashboardOverview | null | undefined) {
|
||||
if (!data?.cps.available) return null;
|
||||
return (data.cps.meituan_commission_cents ?? 0) + (data.cps.jd_commission_cents ?? 0);
|
||||
}
|
||||
|
||||
function revenueYuan(ad: AdRevenueReport | null | undefined, cpsCents: number | null | undefined) {
|
||||
if (ad?.total_revenue_yuan == null && cpsCents == null) return null;
|
||||
return (ad?.total_revenue_yuan ?? 0) + ((cpsCents ?? 0) / 100);
|
||||
}
|
||||
|
||||
function niceMax(v: number) {
|
||||
if (v <= 5) return 5;
|
||||
if (v <= 10) return 10;
|
||||
@@ -99,7 +183,7 @@ function niceMax(v: number) {
|
||||
return Math.ceil(v / base) * base;
|
||||
}
|
||||
|
||||
function StatusText({ children, tone = 'muted' }: { children: React.ReactNode; tone?: 'muted' | 'ok' | 'warn' }) {
|
||||
function StatusText({ children, tone = 'muted' }: { children: ReactNode; tone?: 'muted' | 'ok' | 'warn' }) {
|
||||
return <span className={`status status-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
@@ -107,8 +191,8 @@ function DeltaBadge({
|
||||
children,
|
||||
tone = 'muted',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tone?: 'muted' | 'up' | 'down';
|
||||
children: ReactNode;
|
||||
tone?: DeltaTone;
|
||||
}) {
|
||||
return <span className={`delta delta-${tone}`}>{children}</span>;
|
||||
}
|
||||
@@ -116,11 +200,15 @@ function DeltaBadge({
|
||||
function MiniSpark({
|
||||
color = '#2F6BFF',
|
||||
down,
|
||||
flat,
|
||||
}: {
|
||||
color?: string;
|
||||
down?: boolean;
|
||||
flat?: boolean;
|
||||
}) {
|
||||
const points = down
|
||||
const points = flat
|
||||
? '0,16 13,16 26,15 39,16 52,15 65,16 78,15'
|
||||
: down
|
||||
? '0,8 13,10 26,9 39,14 52,12 65,18 78,20'
|
||||
: '0,24 13,22 26,20 39,15 52,13 65,8 78,5';
|
||||
return (
|
||||
@@ -149,9 +237,9 @@ function StatCard({
|
||||
status?: string;
|
||||
tone?: 'muted' | 'ok' | 'warn';
|
||||
emphasis?: boolean;
|
||||
spark?: 'up' | 'down';
|
||||
delta?: string;
|
||||
deltaTone?: 'muted' | 'up' | 'down';
|
||||
spark?: 'up' | 'down' | 'flat';
|
||||
delta?: ReactNode;
|
||||
deltaTone?: DeltaTone;
|
||||
}) {
|
||||
return (
|
||||
<div className={`stat-card${emphasis ? ' stat-card-emphasis' : ''}`}>
|
||||
@@ -168,7 +256,13 @@ function StatCard({
|
||||
{value}
|
||||
{unit && value !== '--' && <span>{unit}</span>}
|
||||
</div>
|
||||
{spark && <MiniSpark color={spark === 'down' ? '#EF4444' : '#2F6BFF'} down={spark === 'down'} />}
|
||||
{spark && (
|
||||
<MiniSpark
|
||||
color={spark === 'down' ? '#EF4444' : '#2F6BFF'}
|
||||
down={spark === 'down'}
|
||||
flat={spark === 'flat'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{delta ? <DeltaBadge tone={deltaTone}>{delta}</DeltaBadge> : status && <StatusText tone={tone}>{status}</StatusText>}
|
||||
</div>
|
||||
@@ -181,18 +275,20 @@ function RevenueCard({
|
||||
meta,
|
||||
accent,
|
||||
delta,
|
||||
deltaTone = 'up',
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
meta?: { label: string; value: string }[];
|
||||
accent?: 'blue' | 'green' | 'yellow' | 'red';
|
||||
delta?: string;
|
||||
accent?: 'blue' | 'green' | 'yellow' | 'red' | 'jd';
|
||||
delta?: ReactNode;
|
||||
deltaTone?: DeltaTone;
|
||||
}) {
|
||||
return (
|
||||
<div className={`revenue-card ${accent ? `rev-${accent}` : ''}`}>
|
||||
<div className="revenue-top">
|
||||
<div className="revenue-tag">{title}</div>
|
||||
{delta && <DeltaBadge tone="up">{delta}</DeltaBadge>}
|
||||
{delta && <DeltaBadge tone={deltaTone}>{delta}</DeltaBadge>}
|
||||
</div>
|
||||
<div className="revenue-value">{value}</div>
|
||||
{meta && meta.length > 0 && (
|
||||
@@ -349,9 +445,12 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { message } = App.useApp();
|
||||
const [period, setPeriod] = useState<PeriodKey>('yesterday');
|
||||
const [data, setData] = useState<DashboardOverview | null>(null);
|
||||
const [previousData, setPreviousData] = useState<DashboardOverview | null>(null);
|
||||
const [adReport, setAdReport] = useState<AdRevenueReport | null>(null);
|
||||
const [previousAdReport, setPreviousAdReport] = useState<AdRevenueReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [adLoading, setAdLoading] = useState(false);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
@@ -361,7 +460,9 @@ export default function DashboardPage() {
|
||||
const [cpsSyncing, setCpsSyncing] = useState(false);
|
||||
|
||||
const range = useMemo(() => periodRange(period), [period]);
|
||||
const previousRange = useMemo(() => previousPeriodRange(range), [range]);
|
||||
const periodData = data?.period ?? null;
|
||||
const previousPeriodData = previousData?.period ?? null;
|
||||
const adAvgEcpm = avgEcpm(adReport);
|
||||
const rewardVideoAdSummary = adTypeSummary(adReport, 'reward_video');
|
||||
const couponAdSummary = adSceneSummary(adReport, 'coupon');
|
||||
@@ -381,44 +482,84 @@ export default function DashboardPage() {
|
||||
const regularTaskCoinTotal =
|
||||
periodData?.coins.regular_task_coin_total ??
|
||||
((signinCoinTotal ?? 0) + (signinBoostCoinTotal ?? 0) + (taskCoinTotal ?? 0));
|
||||
const cpsCommissionCents = data?.cps.meituan_commission_cents;
|
||||
const cpsAvailable = data?.cps.available === true;
|
||||
const totalRevenueYuan =
|
||||
adReport?.total_revenue_yuan == null && (!cpsAvailable || cpsCommissionCents == null)
|
||||
? null
|
||||
: (adReport?.total_revenue_yuan ?? 0) + (cpsAvailable ? (cpsCommissionCents ?? 0) / 100 : 0);
|
||||
const meituanCpsCommissionCents = data?.cps.meituan_commission_cents;
|
||||
const jdCpsCommissionCents = data?.cps.jd_commission_cents;
|
||||
const totalCpsCommissionCents = cpsCommissionCents(data);
|
||||
const previousTotalCpsCommissionCents = cpsCommissionCents(previousData);
|
||||
const totalRevenueYuan = revenueYuan(adReport, totalCpsCommissionCents);
|
||||
const previousTotalRevenueYuan = revenueYuan(previousAdReport, previousTotalCpsCommissionCents);
|
||||
const yesterdayTrendPoint = periodData?.trend?.[periodData.trend.length - 1];
|
||||
const yesterdayActiveUsers = yesterdayTrendPoint?.active_users ?? null;
|
||||
const arpuYuan =
|
||||
totalRevenueYuan == null || yesterdayActiveUsers == null || yesterdayActiveUsers <= 0
|
||||
? null
|
||||
: totalRevenueYuan / yesterdayActiveUsers;
|
||||
const cpsRevenueText = cpsAvailable ? fmtCents(cpsCommissionCents) : '--';
|
||||
const cpsRevenueText = cpsAvailable ? fmtCents(totalCpsCommissionCents) : '--';
|
||||
const meituanCpsRevenueText = cpsAvailable ? fmtCents(meituanCpsCommissionCents) : '--';
|
||||
const jdCpsRevenueText = cpsAvailable ? fmtCents(jdCpsCommissionCents) : '--';
|
||||
const cpsHitRate = !cpsAvailable || data?.cps.meituan_hit_rate == null ? '--' : pct(data.cps.meituan_hit_rate);
|
||||
const retentionValue =
|
||||
periodData?.users.retention_rate == null
|
||||
? '--'
|
||||
: (periodData.users.retention_rate * 100).toFixed(1);
|
||||
const activeUserDelta = percentDelta(periodData?.users.active, previousPeriodData?.users.active);
|
||||
const newUserDelta = percentDelta(periodData?.users.new, previousPeriodData?.users.new);
|
||||
const retentionDelta = pointDelta(periodData?.users.retention_rate, previousPeriodData?.users.retention_rate);
|
||||
const retentionSpark: 'up' | 'down' | 'flat' | undefined =
|
||||
periodData?.users.retention_rate == null
|
||||
? undefined
|
||||
: retentionDelta.tone === 'down'
|
||||
? 'down'
|
||||
: retentionDelta.tone === 'up' || periodData.users.retention_rate >= 1
|
||||
? 'up'
|
||||
: 'flat';
|
||||
const comparisonTotalDelta = percentDelta(periodData?.comparison.total, previousPeriodData?.comparison.total);
|
||||
const comparisonSuccessDelta = percentDelta(periodData?.comparison.success, previousPeriodData?.comparison.success);
|
||||
const comparisonOrderedDelta = percentDelta(periodData?.comparison.ordered, previousPeriodData?.comparison.ordered);
|
||||
const durationDeltaInfo = durationDelta(
|
||||
periodData?.comparison.average_duration_ms,
|
||||
previousPeriodData?.comparison.average_duration_ms,
|
||||
);
|
||||
const averageSavedDelta = moneyDeltaCents(
|
||||
periodData?.comparison.average_saved_cents,
|
||||
previousPeriodData?.comparison.average_saved_cents,
|
||||
);
|
||||
const totalRevenueDelta = percentDelta(totalRevenueYuan, previousTotalRevenueYuan);
|
||||
const adRevenueDelta = percentDelta(adReport?.total_revenue_yuan, previousAdReport?.total_revenue_yuan);
|
||||
const cpsRevenueDelta = percentDelta(totalCpsCommissionCents, previousTotalCpsCommissionCents);
|
||||
const grantedCoinDelta = percentDelta(periodData?.coins.granted_total, previousPeriodData?.coins.granted_total);
|
||||
const couponCoinRatio = ratioBadge(couponRewardCoinTotal, periodData?.coins.granted_total);
|
||||
const comparisonCoinRatio = ratioBadge(comparisonRewardCoinTotal, periodData?.coins.granted_total);
|
||||
const rewardVideoCoinRatio = ratioBadge(periodData?.coins.reward_video_coin_total, periodData?.coins.granted_total);
|
||||
const regularTaskCoinRatio = ratioBadge(regularTaskCoinTotal, periodData?.coins.granted_total);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setOverviewError(null);
|
||||
api
|
||||
.get<DashboardOverview>('/admin/api/stats/overview', {
|
||||
params: {
|
||||
date_from: range.start.format('YYYY-MM-DD'),
|
||||
date_to: range.end.format('YYYY-MM-DD'),
|
||||
},
|
||||
})
|
||||
.then((r) => {
|
||||
const currentParams = {
|
||||
date_from: range.start.format('YYYY-MM-DD'),
|
||||
date_to: range.end.format('YYYY-MM-DD'),
|
||||
};
|
||||
const previousParams = {
|
||||
date_from: previousRange.start.format('YYYY-MM-DD'),
|
||||
date_to: previousRange.end.format('YYYY-MM-DD'),
|
||||
};
|
||||
Promise.allSettled([
|
||||
api.get<DashboardOverview>('/admin/api/stats/overview', { params: currentParams }),
|
||||
api.get<DashboardOverview>('/admin/api/stats/overview', { params: previousParams }),
|
||||
])
|
||||
.then(([current, previous]) => {
|
||||
if (!alive) return;
|
||||
setData(r.data);
|
||||
setUpdatedAt(dayjs().format('YYYY-MM-DD HH:mm'));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!alive) return;
|
||||
setOverviewError('总览接口加载失败');
|
||||
if (current.status === 'fulfilled') {
|
||||
setData(current.value.data);
|
||||
setUpdatedAt(dayjs().format('YYYY-MM-DD HH:mm'));
|
||||
} else {
|
||||
setData(null);
|
||||
setOverviewError('总览接口加载失败');
|
||||
}
|
||||
setPreviousData(previous.status === 'fulfilled' ? previous.value.data : null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
@@ -426,29 +567,37 @@ export default function DashboardPage() {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range.start, range.end, refreshKey]);
|
||||
}, [range.start, range.end, previousRange.start, previousRange.end, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setAdLoading(true);
|
||||
setAdError(null);
|
||||
api
|
||||
.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||
params: {
|
||||
date_from: range.start.format('YYYY-MM-DD'),
|
||||
date_to: range.end.format('YYYY-MM-DD'),
|
||||
granularity: 'day',
|
||||
limit: 1000,
|
||||
},
|
||||
})
|
||||
.then((r) => {
|
||||
const currentParams = {
|
||||
date_from: range.start.format('YYYY-MM-DD'),
|
||||
date_to: range.end.format('YYYY-MM-DD'),
|
||||
granularity: 'day',
|
||||
limit: 1000,
|
||||
};
|
||||
const previousParams = {
|
||||
date_from: previousRange.start.format('YYYY-MM-DD'),
|
||||
date_to: previousRange.end.format('YYYY-MM-DD'),
|
||||
granularity: 'day',
|
||||
limit: 1000,
|
||||
};
|
||||
Promise.allSettled([
|
||||
api.get<AdRevenueReport>('/admin/api/ad-revenue-report', { params: currentParams }),
|
||||
api.get<AdRevenueReport>('/admin/api/ad-revenue-report', { params: previousParams }),
|
||||
])
|
||||
.then(([current, previous]) => {
|
||||
if (!alive) return;
|
||||
setAdReport(r.data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!alive) return;
|
||||
setAdReport(null);
|
||||
setAdError('广告收益接口加载失败');
|
||||
if (current.status === 'fulfilled') {
|
||||
setAdReport(current.value.data);
|
||||
} else {
|
||||
setAdReport(null);
|
||||
setAdError('广告收益接口加载失败');
|
||||
}
|
||||
setPreviousAdReport(previous.status === 'fulfilled' ? previous.value.data : null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setAdLoading(false);
|
||||
@@ -456,7 +605,7 @@ export default function DashboardPage() {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range.start, range.end]);
|
||||
}, [range.start, range.end, previousRange.start, previousRange.end]);
|
||||
|
||||
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||
|
||||
@@ -464,7 +613,7 @@ export default function DashboardPage() {
|
||||
return <Alert type="error" showIcon message={overviewError ?? '数据大盘加载失败'} />;
|
||||
}
|
||||
|
||||
const syncMeituanCps = async () => {
|
||||
const syncCpsOrders = async () => {
|
||||
setCpsSyncing(true);
|
||||
try {
|
||||
const resp = await api.post<{
|
||||
@@ -476,12 +625,13 @@ export default function DashboardPage() {
|
||||
params: {
|
||||
date_from: range.start.format('YYYY-MM-DD'),
|
||||
date_to: range.end.format('YYYY-MM-DD'),
|
||||
platform: 'all',
|
||||
},
|
||||
});
|
||||
message.success(`已同步 ${resp.data.fetched} 单`);
|
||||
setRefreshKey((v) => v + 1);
|
||||
} catch {
|
||||
message.error('美团订单刷新失败');
|
||||
message.error('CPS 订单刷新失败');
|
||||
} finally {
|
||||
setCpsSyncing(false);
|
||||
}
|
||||
@@ -527,20 +677,20 @@ export default function DashboardPage() {
|
||||
<h3>核心趋势</h3>
|
||||
<span>先看用户、留存与比价活跃度</span>
|
||||
</div>
|
||||
<div className="grid g5">
|
||||
<StatCard title="总用户" value={fmtInt(data.users.total)} status="累计用户数" tone="ok" spark="up" />
|
||||
<div className="grid g4">
|
||||
<StatCard
|
||||
title="活跃用户"
|
||||
value={fmtInt(periodData?.users.active)}
|
||||
status="本期活跃去重"
|
||||
tone="ok"
|
||||
hint="当前按本期内进入 App、完成比价记录、开始领券流程任一满足去重;未完成上报的比价开始事件待后续补埋点。"
|
||||
delta={activeUserDelta.value}
|
||||
deltaTone={activeUserDelta.tone}
|
||||
hint="当前按本期内登录、开始比价、开始领券任一满足去重;比价按 real_compare_start,领券按 real_coupon_start/claim_started。"
|
||||
spark="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="新增用户"
|
||||
value={fmtInt(periodData?.users.new)}
|
||||
status="本期新用户"
|
||||
delta={newUserDelta.value}
|
||||
deltaTone={newUserDelta.tone}
|
||||
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
|
||||
spark="up"
|
||||
/>
|
||||
@@ -548,16 +698,16 @@ export default function DashboardPage() {
|
||||
title={retentionTitle(period)}
|
||||
value={retentionValue}
|
||||
unit="%"
|
||||
status={`${fmtInt(periodData?.users.retained_new_users)} / ${fmtInt(periodData?.users.new)} 新用户`}
|
||||
tone="warn"
|
||||
delta={retentionDelta.value}
|
||||
deltaTone={retentionDelta.tone}
|
||||
hint={periodData?.users.retention_note ?? '暂无留存数据'}
|
||||
spark="down"
|
||||
spark={retentionSpark}
|
||||
/>
|
||||
<StatCard
|
||||
title="比价次数"
|
||||
value={fmtInt(periodData?.comparison.total)}
|
||||
status="本期比价次数"
|
||||
tone="muted"
|
||||
delta={comparisonTotalDelta.value}
|
||||
deltaTone={comparisonTotalDelta.tone}
|
||||
spark="up"
|
||||
/>
|
||||
</div>
|
||||
@@ -571,23 +721,43 @@ export default function DashboardPage() {
|
||||
<span>发起、成功、下单与效率</span>
|
||||
</div>
|
||||
<div className="grid g5">
|
||||
<StatCard title="发起比价" value={fmtInt(periodData?.comparison.total)} unit="次" status="本期发起次数" />
|
||||
<StatCard title="比价成功" value={fmtInt(periodData?.comparison.success)} unit="次" status={pct(compareSuccessRate)} tone="ok" />
|
||||
<StatCard
|
||||
title="发起比价"
|
||||
value={fmtInt(periodData?.comparison.total)}
|
||||
unit="次"
|
||||
delta={comparisonTotalDelta.value}
|
||||
deltaTone={comparisonTotalDelta.tone}
|
||||
/>
|
||||
<StatCard
|
||||
title="比价成功"
|
||||
value={fmtInt(periodData?.comparison.success)}
|
||||
unit="次"
|
||||
delta={comparisonSuccessDelta.value}
|
||||
deltaTone={comparisonSuccessDelta.tone}
|
||||
hint={`当前成功率 ${pct(compareSuccessRate)}`}
|
||||
/>
|
||||
<StatCard
|
||||
title="成功下单"
|
||||
value={fmtInt(periodData?.comparison.ordered)}
|
||||
unit="单"
|
||||
status="已下单记录"
|
||||
delta={comparisonOrderedDelta.value}
|
||||
deltaTone={comparisonOrderedDelta.tone}
|
||||
hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。"
|
||||
/>
|
||||
<StatCard
|
||||
title="平均耗时"
|
||||
value={fmtMsAsSeconds(averageDurationMs)}
|
||||
unit="秒"
|
||||
status={averageDurationMs == null ? '暂无耗时数据' : '本期平均耗时'}
|
||||
delta={durationDeltaInfo.value}
|
||||
deltaTone={durationDeltaInfo.tone}
|
||||
hint="按比价记录页同一口径:取所选日期范围内已上报耗时的比价记录,求平均后换算为秒。"
|
||||
/>
|
||||
<StatCard title="平均节省金额" value={fmtCents(periodData?.comparison.average_saved_cents)} status="有节省的成功比价" />
|
||||
<StatCard
|
||||
title="平均节省金额"
|
||||
value={fmtCents(periodData?.comparison.average_saved_cents)}
|
||||
delta={averageSavedDelta.value}
|
||||
deltaTone={averageSavedDelta.tone}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -597,7 +767,7 @@ export default function DashboardPage() {
|
||||
<h3>变现</h3>
|
||||
<span>
|
||||
{cpsAvailable
|
||||
? (adError ? '广告收益接口当前后端不可用,美团 CPS 已接入' : '广告收益与美团 CPS 已接入')
|
||||
? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入')
|
||||
: (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -607,6 +777,8 @@ export default function DashboardPage() {
|
||||
title="总收入"
|
||||
value={adLoading ? '--' : fmtYuan(totalRevenueYuan)}
|
||||
accent="blue"
|
||||
delta={totalRevenueDelta.value}
|
||||
deltaTone={totalRevenueDelta.tone}
|
||||
meta={[
|
||||
{ label: '广告收入', value: adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan) },
|
||||
{ label: 'CPS 收入', value: cpsRevenueText },
|
||||
@@ -615,38 +787,58 @@ export default function DashboardPage() {
|
||||
<RevenueCard
|
||||
title="广告收入"
|
||||
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
|
||||
meta={[{ label: '展示数', value: adLoading ? '--' : fmtInt(adReport?.total_impressions) }]}
|
||||
delta={adRevenueDelta.value}
|
||||
deltaTone={adRevenueDelta.tone}
|
||||
meta={[
|
||||
{
|
||||
label: '占总收入',
|
||||
value:
|
||||
totalRevenueYuan && totalRevenueYuan > 0 && adReport?.total_revenue_yuan != null
|
||||
? `${((adReport.total_revenue_yuan / totalRevenueYuan) * 100).toFixed(1)}%`
|
||||
: '--',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="CPS 收入"
|
||||
value={cpsRevenueText}
|
||||
meta={[{ label: '来源', value: cpsAvailable ? '美团已接,淘宝/京东暂空' : '订单/佣金待接' }]}
|
||||
delta={cpsRevenueDelta.value}
|
||||
deltaTone={cpsRevenueDelta.tone}
|
||||
meta={[
|
||||
{
|
||||
label: '占总收入',
|
||||
value:
|
||||
totalRevenueYuan && totalRevenueYuan > 0 && totalCpsCommissionCents != null
|
||||
? `${(((totalCpsCommissionCents / 100) / totalRevenueYuan) * 100).toFixed(1)}%`
|
||||
: '--',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="ARPU"
|
||||
value={adLoading ? '--' : fmtYuan(arpuYuan)}
|
||||
meta={[
|
||||
{ label: '口径', value: '收入 / 活跃用户' },
|
||||
{ label: 'ARPPU', value: '待确认付费用户口径' },
|
||||
{ label: '口径', value: '总收入 / 昨日活跃用户' },
|
||||
{ label: '分母', value: yesterdayActiveUsers == null ? '--' : `${fmtInt(yesterdayActiveUsers)} 人` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sub-label cps-sub-label">
|
||||
<span>CPS 收益</span>
|
||||
<button type="button" onClick={syncMeituanCps} disabled={cpsSyncing}>
|
||||
{cpsSyncing ? '刷新中' : '刷新美团订单'}
|
||||
<button type="button" onClick={syncCpsOrders} disabled={cpsSyncing}>
|
||||
{cpsSyncing ? '刷新中' : '刷新 CPS 订单'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid g4">
|
||||
<RevenueCard
|
||||
title="总 CPS 收益"
|
||||
value={cpsRevenueText}
|
||||
meta={[{ label: '来源', value: cpsAvailable ? '美团订单佣金,淘宝/京东暂空' : '订单/佣金待接' }]}
|
||||
meta={[{ label: '来源', value: cpsAvailable ? '美团+京东订单佣金,淘宝暂未接入' : '订单/佣金待接' }]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="美团 CPS"
|
||||
value={cpsRevenueText}
|
||||
value={meituanCpsRevenueText}
|
||||
meta={[
|
||||
{ label: '订单量', value: cpsAvailable ? fmtInt(data?.cps.meituan_order_count) : '待订单数据' },
|
||||
{
|
||||
@@ -658,8 +850,16 @@ export default function DashboardPage() {
|
||||
]}
|
||||
accent="yellow"
|
||||
/>
|
||||
<RevenueCard title="淘宝 CPS" value="--" meta={[{ label: '状态', value: '后续接入后填写' }]} accent="red" />
|
||||
<RevenueCard title="京东 CPS" value="--" meta={[{ label: '状态', value: '后续接入后填写' }]} accent="red" />
|
||||
<RevenueCard title="淘宝 CPS" value="--" meta={[{ label: '状态', value: '暂未接入' }]} accent="red" />
|
||||
<RevenueCard
|
||||
title="京东 CPS"
|
||||
value={jdCpsRevenueText}
|
||||
meta={[
|
||||
{ label: '有效订单', value: cpsAvailable ? fmtInt(data?.cps.jd_order_count) : '待订单数据' },
|
||||
{ label: '无效订单', value: cpsAvailable ? fmtInt(data?.cps.jd_invalid_count ?? 0) : '待订单数据' },
|
||||
]}
|
||||
accent="jd"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sub-label">广告收益</div>
|
||||
@@ -705,32 +905,58 @@ export default function DashboardPage() {
|
||||
<span className="bar" />
|
||||
<h3>金币经济</h3>
|
||||
<Tag color="warning">风控重点</Tag>
|
||||
<span>发放是账面负债,提现是真实现金成本</span>
|
||||
<span>发放 = 平台负债,提现 = 真实现金成本</span>
|
||||
</div>
|
||||
<div className="grid g6">
|
||||
<StatCard title="本期发放金币" value={fmtInt(periodData?.coins.granted_total)} status="本期发放合计" />
|
||||
<StatCard
|
||||
title="本期发放金币"
|
||||
value={fmtInt(periodData?.coins.granted_total)}
|
||||
delta={grantedCoinDelta.value}
|
||||
deltaTone={grantedCoinDelta.tone}
|
||||
emphasis
|
||||
/>
|
||||
<StatCard
|
||||
title="领券奖励金币"
|
||||
value={fmtInt(couponRewardCoinTotal)}
|
||||
status="领券相关发放"
|
||||
delta={couponCoinRatio.value}
|
||||
deltaTone={couponCoinRatio.tone}
|
||||
/>
|
||||
<StatCard
|
||||
title="比价奖励金币"
|
||||
value={fmtInt(comparisonRewardCoinTotal)}
|
||||
status="比价相关发放"
|
||||
delta={comparisonCoinRatio.value}
|
||||
deltaTone={comparisonCoinRatio.tone}
|
||||
/>
|
||||
<StatCard
|
||||
title="激励视频金币"
|
||||
value={fmtInt(periodData?.coins.reward_video_coin_total)}
|
||||
delta={rewardVideoCoinRatio.value}
|
||||
deltaTone={rewardVideoCoinRatio.tone}
|
||||
hint="激励视频金币已按产品口径计入领券奖励金币,这里单独展示来源占比。"
|
||||
/>
|
||||
<StatCard title="激励视频金币" value="--" status="并入领券奖励" />
|
||||
<StatCard
|
||||
title="常规任务金币"
|
||||
value={fmtInt(regularTaskCoinTotal)}
|
||||
status="任务等发放"
|
||||
delta={regularTaskCoinRatio.value}
|
||||
deltaTone={regularTaskCoinRatio.tone}
|
||||
/>
|
||||
<StatCard
|
||||
title="本期提现金额"
|
||||
value={fmtCents(periodData?.cash.withdraw_success_cents)}
|
||||
delta="实付现金"
|
||||
deltaTone="flat"
|
||||
/>
|
||||
<StatCard title="本期提现金额" value={fmtCents(periodData?.cash.withdraw_success_cents)} status="成功提现" />
|
||||
</div>
|
||||
<div className="risk-note">
|
||||
本期已发放 {fmtInt(periodData?.coins.granted_total)} 金币,成功提现 {fmtCents(periodData?.cash.withdraw_success_cents)};
|
||||
累计已发放 {fmtInt(data.coins.granted_total)} 金币,累计成功提现 {fmtCents(data.cash.withdraw_success_cents)}。
|
||||
金币分类已按产品口径归类;邀请、后台手动加金币、福利页广告、无法判断来源的广告不进入上述分类。
|
||||
⚠️
|
||||
<div>
|
||||
本期 4 项奖励合计发放 <b>{fmtCoinAmount(periodData?.coins.granted_total)}</b> 金币
|
||||
(账面负债 ≈ <b>{fmtYuan((periodData?.coins.granted_total ?? 0) / 1000)}</b>),
|
||||
实际提现兑付 <b>{fmtCents(periodData?.cash.withdraw_success_cents)}</b>。
|
||||
累计已发放 <b>{fmtCoinAmount(data.coins.granted_total)}</b> 金币但累计真实提现仅
|
||||
<b>{fmtCents(data.cash.withdraw_success_cents)}</b>,发放与现金兑付背离属正常(金币沉淀/过期);
|
||||
需盯紧各来源占比突变与异常领取,防薅羊毛。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -772,7 +998,7 @@ export default function DashboardPage() {
|
||||
--coin: #ffb300;
|
||||
--p-mt-waimai: #ffc300;
|
||||
--p-taobao: #ff4400;
|
||||
--p-jd: #e1251b;
|
||||
--p-jd: #b91c1c;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.04);
|
||||
color: var(--ink);
|
||||
@@ -967,10 +1193,17 @@ export default function DashboardPage() {
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-muted,
|
||||
.delta-muted {
|
||||
.delta-muted,
|
||||
.delta-flat {
|
||||
color: var(--ink-3);
|
||||
background: #f1f3f7;
|
||||
}
|
||||
.delta .cmp {
|
||||
margin-left: 3px;
|
||||
color: var(--ink-3);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
.status-ok,
|
||||
.delta-up {
|
||||
color: var(--up);
|
||||
@@ -1144,6 +1377,10 @@ export default function DashboardPage() {
|
||||
background: var(--p-taobao);
|
||||
color: #fff;
|
||||
}
|
||||
.rev-jd .revenue-tag {
|
||||
background: var(--p-jd);
|
||||
color: #fff;
|
||||
}
|
||||
.revenue-value {
|
||||
color: var(--ink);
|
||||
font-family: "DIN Alternate", "Helvetica Neue", sans-serif;
|
||||
|
||||
@@ -537,6 +537,11 @@ export interface DashboardOverview {
|
||||
meituan_miss_count: number;
|
||||
meituan_unknown_rate_count: number;
|
||||
meituan_hit_rate: number | null;
|
||||
jd_order_count?: number;
|
||||
jd_commission_cents?: number;
|
||||
jd_actual_commission_cents?: number;
|
||||
jd_estimated_commission_cents?: number;
|
||||
jd_invalid_count?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user