Files
shaguabijia-admin-web/src/app/(main)/dashboard/page.tsx
T
chenshuobo 7f5dde5c2f 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>
2026-06-30 21:56:55 +08:00

1516 lines
52 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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';
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 },
{ key: '7d', label: '近 7 日', days: 7 },
{ key: '30d', label: '近 30 日', days: 30 },
];
const TREND_SERIES: { key: TrendSeriesKey; label: string; className: string }[] = [
{ key: 'dau', label: 'DAU', className: 'line-blue' },
{ key: 'newUsers', label: '新增', className: 'line-green' },
{ key: 'comparisons', label: '比价次数', className: 'line-yellow' },
];
const numberFmt = new Intl.NumberFormat('zh-CN');
const moneyFmt = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const fmtInt = (v: number | null | undefined) => (v == null ? '--' : numberFmt.format(v));
const fmtCents = (cents: number | null | undefined) => (cents == null ? '--' : `¥${moneyFmt.format(cents / 100)}`);
const fmtYuan = (yuan: number | null | undefined) => (yuan == null ? '--' : `¥${moneyFmt.format(yuan)}`);
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];
const end = dayjs().subtract(1, 'day');
const start = end.subtract(p.days - 1, 'day');
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 日新增留存';
return '昨日新增留存';
}
function avgEcpm(report: AdRevenueReport | null) {
if (!report || report.total_impressions <= 0) return null;
return (report.total_revenue_yuan / report.total_impressions) * 1000;
}
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
if (!report) return null;
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType];
const matched = report.items.filter((it) => it.ad_type === adType);
if (matched.length === 0) return null;
return {
ad_type: adType,
impressions: matched.reduce((sum, it) => sum + it.impressions, 0),
revenue_yuan: matched.reduce((sum, it) => sum + it.revenue_yuan, 0),
expected_coin: matched.reduce((sum, it) => sum + it.expected_coin, 0),
actual_coin: matched.reduce((sum, it) => sum + it.actual_coin, 0),
};
}
function adSceneSummary(report: AdRevenueReport | null, feedScene: 'coupon' | 'comparison') {
if (!report) return null;
const matched = report.items.filter((it) => it.feed_scene === feedScene);
if (matched.length === 0) return null;
return {
impressions: matched.reduce((sum, it) => sum + it.impressions, 0),
revenue_yuan: matched.reduce((sum, it) => sum + it.revenue_yuan, 0),
};
}
function sceneAdText(
loading: boolean,
report: AdRevenueReport | null,
summary: ReturnType<typeof adSceneSummary>,
) {
if (loading || !report) return '--';
return fmtYuan(summary?.revenue_yuan ?? 0);
}
function sceneAdEcpm(summary: ReturnType<typeof adSceneSummary>) {
if (!summary || summary.impressions <= 0) return '--';
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;
const base = 10 ** Math.floor(Math.log10(v));
return Math.ceil(v / base) * base;
}
function StatusText({ children, tone = 'muted' }: { children: ReactNode; tone?: 'muted' | 'ok' | 'warn' }) {
return <span className={`status status-${tone}`}>{children}</span>;
}
function DeltaBadge({
children,
tone = 'muted',
}: {
children: ReactNode;
tone?: DeltaTone;
}) {
return <span className={`delta delta-${tone}`}>{children}</span>;
}
function MiniSpark({
color = '#2F6BFF',
down,
flat,
}: {
color?: string;
down?: boolean;
flat?: boolean;
}) {
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 (
<svg className="spark" viewBox="0 0 78 32" aria-hidden>
<polyline fill="none" stroke={color} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" points={points} />
</svg>
);
}
function StatCard({
title,
value,
unit,
hint,
status,
tone,
emphasis,
spark,
delta,
deltaTone,
}: {
title: string;
value: string | number;
unit?: string;
hint?: string;
status?: string;
tone?: 'muted' | 'ok' | 'warn';
emphasis?: boolean;
spark?: 'up' | 'down' | 'flat';
delta?: ReactNode;
deltaTone?: DeltaTone;
}) {
return (
<div className={`stat-card${emphasis ? ' stat-card-emphasis' : ''}`}>
<div className="stat-title">
<span>{title}</span>
{hint && (
<Tooltip title={hint}>
<InfoCircleOutlined />
</Tooltip>
)}
</div>
<div className="stat-row">
<div className="stat-value">
{value}
{unit && value !== '--' && <span>{unit}</span>}
</div>
{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>
);
}
function RevenueCard({
title,
value,
meta,
accent,
delta,
deltaTone = 'up',
}: {
title: string;
value: string;
meta?: { label: string; value: 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={deltaTone}>{delta}</DeltaBadge>}
</div>
<div className="revenue-value">{value}</div>
{meta && meta.length > 0 && (
<div className="revenue-meta">
{meta.map((it) => (
<span key={it.label}>
{it.label} <b>{it.value}</b>
</span>
))}
</div>
)}
</div>
);
}
function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: DashboardTrendPoint[] }) {
const [visibleSeries, setVisibleSeries] = useState<Record<TrendSeriesKey, boolean>>({
dau: true,
newUsers: true,
comparisons: true,
});
const seriesConfig = {
dau: { field: 'active_users', color: '#2F6BFF' },
newUsers: { field: 'new_users', color: '#18B368' },
comparisons: { field: 'comparisons', color: '#FFB300' },
} satisfies Record<TrendSeriesKey, { field: keyof DashboardTrendPoint; color: string }>;
const chart = { left: 44, right: 880, top: 28, bottom: 210 };
const visibleKeys = TREND_SERIES.map((s) => s.key).filter((key) => visibleSeries[key]);
const maxValue = niceMax(
Math.max(
0,
...points.flatMap((p) => visibleKeys.map((key) => Number(p[seriesConfig[key].field]) || 0)),
),
);
const xFor = (idx: number) =>
points.length <= 1
? (chart.left + chart.right) / 2
: chart.left + ((chart.right - chart.left) * idx) / (points.length - 1);
const yFor = (value: number) =>
chart.bottom - ((chart.bottom - chart.top) * value) / maxValue;
const seriesPoints = (key: TrendSeriesKey) =>
points
.map((p, idx) => `${xFor(idx)},${yFor(Number(p[seriesConfig[key].field]) || 0)}`)
.join(' ');
const areaPath = (key: TrendSeriesKey) => {
if (points.length < 2) return '';
const line = points
.map((p, idx) => `L${xFor(idx)},${yFor(Number(p[seriesConfig[key].field]) || 0)}`)
.join(' ');
return `M${chart.left},${chart.bottom} ${line.replace(/^L/, 'L')} L${chart.right},${chart.bottom} Z`;
};
const labelStep = Math.max(1, Math.ceil(points.length / 6));
const toggleSeries = (key: TrendSeriesKey) => {
setVisibleSeries((prev) => {
const visibleCount = Object.values(prev).filter(Boolean).length;
if (prev[key] && visibleCount === 1) return prev;
return { ...prev, [key]: !prev[key] };
});
};
return (
<div className="chart-card">
<div className="chart-head">
<div className="legend">
{TREND_SERIES.map((series) => (
<label key={series.key} className={visibleSeries[series.key] ? undefined : 'is-off'}>
<input
type="checkbox"
checked={visibleSeries[series.key]}
onChange={() => toggleSeries(series.key)}
aria-label={`切换${series.label}曲线`}
/>
<i className={series.className} />
{series.label}
</label>
))}
</div>
<div className="chart-actions">
<button className="on"></button>
<button disabled title="当前仅有日粒度数据"></button>
</div>
</div>
<div className="chart-empty">
<svg viewBox="0 0 900 240" width="100%" height="220" preserveAspectRatio="none" aria-hidden>
<g stroke="#edf0f5" strokeWidth="1">
<line x1="44" y1="28" x2="880" y2="28" />
<line x1="44" y1="76" x2="880" y2="76" />
<line x1="44" y1="124" x2="880" y2="124" />
<line x1="44" y1="172" x2="880" y2="172" />
<line x1="44" y1="210" x2="880" y2="210" />
</g>
<g fill="#97A3B8" fontSize="12" fontWeight="600">
{[1, 0.75, 0.5, 0.25, 0].map((ratio, idx) => (
<text key={ratio} x="36" y={[32, 80, 128, 176, 214][idx]} textAnchor="end">
{compact(Math.round(maxValue * ratio))}
</text>
))}
</g>
{points.length === 0 ? (
<text x="450" y="124" fill="#97A3B8" fontSize="13" fontWeight="600" textAnchor="middle">
</text>
) : (
<>
{visibleSeries.dau && points.length > 1 && (
<path
d={areaPath('dau')}
fill="#2F6BFF"
opacity="0.07"
/>
)}
{TREND_SERIES.map((series) =>
visibleSeries[series.key] ? (
<g key={series.key}>
{points.length > 1 && (
<polyline
fill="none"
stroke={seriesConfig[series.key].color}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
points={seriesPoints(series.key)}
/>
)}
{points.map((p, idx) => (
<circle
key={`${series.key}-${p.date}`}
cx={xFor(idx)}
cy={yFor(Number(p[seriesConfig[series.key].field]) || 0)}
r="3.2"
fill={seriesConfig[series.key].color}
/>
))}
</g>
) : null,
)}
<g fill="#97A3B8" fontSize="12" fontWeight="600">
{points.map((p, idx) =>
idx === 0 || idx === points.length - 1 || idx % labelStep === 0 ? (
<text key={p.date} x={xFor(idx)} y="235" textAnchor={idx === points.length - 1 ? 'end' : 'middle'}>
{dayjs(p.date).format('MM-DD')}
</text>
) : null,
)}
</g>
</>
)}
</svg>
<div className="chart-empty-text"> · {rangeLabel} · </div>
</div>
</div>
);
}
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);
const [adError, setAdError] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
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');
const comparisonAdSummary = adSceneSummary(adReport, 'comparison');
const rewardVideoAdText = adLoading || !adReport ? '--' : fmtYuan(rewardVideoAdSummary?.revenue_yuan ?? 0);
const rewardVideoEcpm =
rewardVideoAdSummary && rewardVideoAdSummary.impressions > 0
? fmtYuan((rewardVideoAdSummary.revenue_yuan / rewardVideoAdSummary.impressions) * 1000)
: '--';
const compareSuccessRate = periodData?.comparison.success_rate ?? null;
const averageDurationMs = periodData?.comparison.average_duration_ms ?? null;
const couponRewardCoinTotal = periodData?.coins.coupon_reward_coin_total;
const comparisonRewardCoinTotal = periodData?.coins.comparison_reward_coin_total;
const signinCoinTotal = periodData?.coins.signin_coin_total;
const signinBoostCoinTotal = periodData?.coins.signin_boost_coin_total;
const taskCoinTotal = periodData?.coins.task_coin_total;
const regularTaskCoinTotal =
periodData?.coins.regular_task_coin_total ??
((signinCoinTotal ?? 0) + (signinBoostCoinTotal ?? 0) + (taskCoinTotal ?? 0));
const cpsAvailable = data?.cps.available === true;
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(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);
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;
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);
});
return () => {
alive = false;
};
}, [range.start, range.end, previousRange.start, previousRange.end, refreshKey]);
useEffect(() => {
let alive = true;
setAdLoading(true);
setAdError(null);
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;
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);
});
return () => {
alive = false;
};
}, [range.start, range.end, previousRange.start, previousRange.end]);
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
if (!data) {
return <Alert type="error" showIcon message={overviewError ?? '数据大盘加载失败'} />;
}
const syncCpsOrders = async () => {
setCpsSyncing(true);
try {
const resp = await api.post<{
fetched: number;
inserted: number;
updated: number;
pages: number;
}>('/admin/api/cps/orders/reconcile', null, {
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('CPS 订单刷新失败');
} finally {
setCpsSyncing(false);
}
};
return (
<div className="dashboard-v2">
<div className="crumb-line"> / <b></b></div>
<div className="pagehead">
<div>
<h1></h1>
<p>
{updatedAt ?? '--'} · · 00:00
</p>
</div>
<div className="controls">
<div className="segment" role="tablist" aria-label="日期窗口">
{PERIODS.map((it) => (
<button
key={it.key}
className={period === it.key ? 'on' : undefined}
onClick={() => setPeriod(it.key)}
>
{it.label}
</button>
))}
</div>
<div className="date-pill">
<CalendarOutlined />
{range.label}
</div>
<Select
value="prev"
style={{ width: 132 }}
options={[{ value: 'prev', label: '环比上周期' }, { value: 'none', label: '不对比' }]}
/>
</div>
</div>
<section className="section">
<div className="section-head">
<span className="bar" />
<h3></h3>
<span></span>
</div>
<div className="grid g4">
<StatCard
title="活跃用户"
value={fmtInt(periodData?.users.active)}
delta={activeUserDelta.value}
deltaTone={activeUserDelta.tone}
hint="当前按本期内登录、开始比价、开始领券任一满足去重;比价按 real_compare_start,领券按 real_coupon_start/claim_started。"
spark="up"
/>
<StatCard
title="新增用户"
value={fmtInt(periodData?.users.new)}
delta={newUserDelta.value}
deltaTone={newUserDelta.tone}
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
spark="up"
/>
<StatCard
title={retentionTitle(period)}
value={retentionValue}
unit="%"
delta={retentionDelta.value}
deltaTone={retentionDelta.tone}
hint={periodData?.users.retention_note ?? '暂无留存数据'}
spark={retentionSpark}
/>
<StatCard
title="比价次数"
value={fmtInt(periodData?.comparison.total)}
delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone}
spark="up"
/>
</div>
<TrendChart rangeLabel={range.label} points={periodData?.trend ?? []} />
</section>
<section className="section">
<div className="section-head">
<span className="bar" />
<h3></h3>
<span></span>
</div>
<div className="grid g5">
<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="单"
delta={comparisonOrderedDelta.value}
deltaTone={comparisonOrderedDelta.tone}
hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。"
/>
<StatCard
title="平均耗时"
value={fmtMsAsSeconds(averageDurationMs)}
unit="秒"
delta={durationDeltaInfo.value}
deltaTone={durationDeltaInfo.tone}
hint="按比价记录页同一口径:取所选日期范围内已上报耗时的比价记录,求平均后换算为秒。"
/>
<StatCard
title="平均节省金额"
value={fmtCents(periodData?.comparison.average_saved_cents)}
delta={averageSavedDelta.value}
deltaTone={averageSavedDelta.tone}
/>
</div>
</section>
<section className="section">
<div className="section-head">
<span className="bar" />
<h3></h3>
<span>
{cpsAvailable
? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入')
: (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
</span>
</div>
<div className="sub-label"></div>
<div className="grid g4">
<RevenueCard
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 },
]}
/>
<RevenueCard
title="广告收入"
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
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}
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: '分母', value: yesterdayActiveUsers == null ? '--' : `${fmtInt(yesterdayActiveUsers)}` },
]}
/>
</div>
<div className="sub-label cps-sub-label">
<span>CPS </span>
<button type="button" onClick={syncCpsOrders} disabled={cpsSyncing}>
{cpsSyncing ? '刷新中' : '刷新 CPS 订单'}
</button>
</div>
<div className="grid g4">
<RevenueCard
title="总 CPS 收益"
value={cpsRevenueText}
meta={[{ label: '来源', value: cpsAvailable ? '美团+京东订单佣金,淘宝暂未接入' : '订单/佣金待接' }]}
/>
<RevenueCard
title="美团 CPS"
value={meituanCpsRevenueText}
meta={[
{ label: '订单量', value: cpsAvailable ? fmtInt(data?.cps.meituan_order_count) : '待订单数据' },
{
label: '红包命中',
value: cpsAvailable
? `${cpsHitRate} (${fmtInt(data?.cps.meituan_hit_count)}/${fmtInt((data?.cps.meituan_hit_count ?? 0) + (data?.cps.meituan_miss_count ?? 0))})`
: '待订单数据',
},
]}
accent="yellow"
/>
<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>
<div className="grid g4">
<RevenueCard
title="总广告收益"
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
meta={[
{ label: '平均 eCPM', value: adLoading ? '--' : fmtYuan(adAvgEcpm ?? undefined) },
{ label: '展示数', value: adLoading ? '--' : fmtInt(adReport?.total_impressions) },
]}
accent="green"
/>
<RevenueCard
title="领券广告"
value={sceneAdText(adLoading, adReport, couponAdSummary)}
meta={[
{ label: '平均 eCPM', value: sceneAdEcpm(couponAdSummary) },
{ label: '展示数', value: adLoading ? '--' : fmtInt(couponAdSummary?.impressions ?? 0) },
]}
/>
<RevenueCard
title="比价广告"
value={sceneAdText(adLoading, adReport, comparisonAdSummary)}
meta={[
{ label: '平均 eCPM', value: sceneAdEcpm(comparisonAdSummary) },
{ label: '展示数', value: adLoading ? '--' : fmtInt(comparisonAdSummary?.impressions ?? 0) },
]}
/>
<RevenueCard
title="激励视频广告"
value={rewardVideoAdText}
meta={[
{ label: '平均 eCPM', value: rewardVideoEcpm },
{ label: '展示数', value: adLoading ? '--' : fmtInt(rewardVideoAdSummary?.impressions ?? 0) },
]}
/>
</div>
</section>
<section className="section">
<div className="section-head">
<span className="bar" />
<h3></h3>
<Tag color="warning"></Tag>
<span> = = </span>
</div>
<div className="grid g6">
<StatCard
title="本期发放金币"
value={fmtInt(periodData?.coins.granted_total)}
delta={grantedCoinDelta.value}
deltaTone={grantedCoinDelta.tone}
emphasis
/>
<StatCard
title="领券奖励金币"
value={fmtInt(couponRewardCoinTotal)}
delta={couponCoinRatio.value}
deltaTone={couponCoinRatio.tone}
/>
<StatCard
title="比价奖励金币"
value={fmtInt(comparisonRewardCoinTotal)}
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={fmtInt(regularTaskCoinTotal)}
delta={regularTaskCoinRatio.value}
deltaTone={regularTaskCoinRatio.tone}
/>
<StatCard
title="本期提现金额"
value={fmtCents(periodData?.cash.withdraw_success_cents)}
delta="实付现金"
deltaTone="flat"
/>
</div>
<div className="risk-note">
<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>
<section className="section totals-section">
<div className="section-head">
<span className="bar bar-muted" />
<h3></h3>
<span></span>
</div>
<div className="totals">
<div><span></span><b>{fmtInt(data.users.total)}</b><em></em></div>
<div><span></span><b>{fmtInt(data.comparison.total)}</b><em></em></div>
<div><span></span><b>{fmtInt(data.coins.granted_total)}</b></div>
<div><span></span><b>{fmtCents(data.cash.withdraw_success_cents)}</b></div>
</div>
</section>
{!data.cps.available && (
<Alert style={{ marginTop: 16 }} type="info" showIcon message={`CPS 收入:${data.cps.note}`} />
)}
<style jsx global>{`
.dashboard-v2 {
--brand-blue: #2563eb;
--brand-blue-soft: #eaf1fe;
--page-bg: #f4f6fa;
--card: #fff;
--border: #e8ebf1;
--border-soft: #eff1f6;
--ink: #1f2733;
--ink-2: #5b6573;
--ink-3: #94a0b1;
--up: #16a34a;
--up-bg: #e9f7ef;
--down: #dc2626;
--down-bg: #fdecec;
--warn: #d97706;
--warn-bg: #fef3e2;
--coin: #ffb300;
--p-mt-waimai: #ffc300;
--p-taobao: #ff4400;
--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);
background: var(--page-bg);
margin: -24px;
padding: 22px 24px 48px;
max-width: 1480px;
min-height: calc(100vh - 64px);
font-size: 14px;
}
.crumb-line {
margin-bottom: 18px;
color: var(--ink-3);
font-size: 13px;
}
.crumb-line b {
color: var(--ink);
font-weight: 600;
}
.pagehead {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 14px;
margin-bottom: 18px;
}
.pagehead h1 {
margin: 0;
color: var(--ink);
font-size: 21px;
font-weight: 600;
line-height: 1.25;
}
.pagehead p {
margin: 4px 0 0;
color: var(--ink-3);
font-size: 12.5px;
}
.controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.segment {
display: inline-flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--border);
border-radius: 9px;
background: #fff;
}
.segment button {
border: 0;
border-radius: 7px;
background: transparent;
color: var(--ink-2);
cursor: pointer;
font: inherit;
font-size: 13px;
padding: 6px 12px;
transition: 0.12s;
}
.segment button:hover {
color: var(--ink);
}
.segment button.on {
background: var(--brand-blue);
color: #fff;
font-weight: 600;
}
.date-pill {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--border);
border-radius: 9px;
background: #fff;
color: var(--ink-2);
cursor: pointer;
font-size: 13px;
padding: 7px 12px;
}
.section {
margin-bottom: 26px;
}
.section-head {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 13px;
}
.section-head .bar {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--brand-blue);
}
.section-head .bar-muted {
background: var(--ink-3);
}
.section-head h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
font-weight: 600;
}
.section-head span:not(.bar) {
color: var(--ink-3);
font-size: 12px;
font-weight: 400;
}
.grid {
display: grid;
gap: 14px;
}
.g5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.g4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.g6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.stat-card,
.revenue-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.stat-card {
height: 114px;
padding: 16px 17px;
overflow: hidden;
}
.stat-card-emphasis {
border-left: 3px solid var(--brand-blue);
padding-left: 14px;
}
.stat-title {
display: flex;
align-items: center;
gap: 5px;
color: var(--ink-3);
font-size: 12.5px;
}
.stat-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
}
.stat-value {
color: var(--ink);
font-family: "DIN Alternate", "Helvetica Neue", sans-serif;
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
line-height: 1;
min-width: 0;
}
.stat-value span {
margin-left: 3px;
color: var(--ink-3);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
font-weight: 400;
}
.spark {
width: 78px;
height: 32px;
flex-shrink: 0;
}
.status,
.delta {
display: inline-flex;
align-items: center;
gap: 3px;
max-width: 100%;
margin-top: 9px;
border-radius: 6px;
padding: 2px 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: "DIN Alternate", "Helvetica Neue", sans-serif;
font-size: 12px;
font-weight: 600;
}
.status-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);
background: var(--up-bg);
}
.status-warn {
color: var(--warn);
background: var(--warn-bg);
}
.delta-down {
color: var(--down);
background: var(--down-bg);
}
.chart-card {
margin-top: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
box-shadow: var(--shadow);
padding: 18px 18px 8px;
}
.chart-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.legend {
display: flex;
gap: 16px;
}
.legend label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-2);
cursor: pointer;
font-size: 12.5px;
user-select: none;
}
.legend label.is-off {
color: var(--ink-3);
}
.legend input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--brand-blue);
cursor: pointer;
}
.legend i {
width: 9px;
height: 9px;
border-radius: 3px;
display: inline-block;
}
.legend label.is-off i {
opacity: 0.38;
}
.line-blue {
background: var(--brand-blue);
}
.line-green {
background: var(--up);
}
.line-yellow {
background: var(--coin);
}
.chart-actions {
display: flex;
gap: 4px;
}
.chart-actions button {
border: 1px solid var(--border);
border-radius: 7px;
background: #fff;
color: var(--ink-2);
cursor: pointer;
font: inherit;
font-size: 12px;
padding: 4px 10px;
}
.chart-actions button.on {
border-color: transparent;
background: var(--brand-blue-soft);
color: var(--brand-blue);
font-weight: 600;
}
.chart-actions button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.chart-empty {
position: relative;
min-height: 240px;
}
.chart-empty-text {
position: absolute;
right: 10px;
top: 6px;
color: var(--ink-3);
font-size: 12px;
}
.sub-label {
display: flex;
align-items: center;
gap: 7px;
margin: 2px 0 10px;
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
}
.sub-label::before {
content: "";
display: inline-block;
width: 3px;
height: 12px;
border-radius: 2px;
background: var(--ink-3);
}
.cps-sub-label {
justify-content: flex-start;
}
.cps-sub-label button {
margin-left: 8px;
border: 1px solid var(--border);
border-radius: 7px;
background: #fff;
color: var(--brand-blue);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
}
.cps-sub-label button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.revenue-card {
display: flex;
flex-direction: column;
min-height: 154px;
padding: 16px 17px;
}
.revenue-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
}
.revenue-tag {
border-radius: 6px;
background: #f1f3f7;
color: var(--ink-2);
font-size: 12px;
font-weight: 600;
padding: 3px 9px;
}
.rev-blue .revenue-tag,
.rev-green .revenue-tag {
background: var(--brand-blue);
color: #fff;
}
.rev-yellow .revenue-tag {
background: var(--p-mt-waimai);
color: #1a1a1a;
}
.rev-red .revenue-tag {
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;
font-size: 26px;
font-weight: 700;
letter-spacing: 0;
line-height: 1;
}
.rev-blue .revenue-value {
color: var(--brand-blue);
}
.revenue-meta {
display: flex;
gap: 18px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-soft);
}
.revenue-meta span {
display: flex;
flex-direction: column;
gap: 3px;
color: var(--ink-3);
font-size: 11.5px;
min-width: 0;
}
.revenue-meta b {
color: var(--ink);
font-family: "DIN Alternate", "Helvetica Neue", sans-serif;
font-size: 15px;
font-weight: 700;
white-space: nowrap;
}
.risk-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
border: 1px solid #f6e0be;
border-radius: 9px;
background: var(--warn-bg);
color: #92560a;
font-size: 12.5px;
line-height: 1.65;
padding: 11px 14px;
}
.totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
box-shadow: var(--shadow);
}
.totals div {
min-height: 96px;
padding: 18px 20px;
border-right: 1px solid var(--border-soft);
}
.totals div:last-child {
border-right: 0;
}
.totals span {
display: block;
color: var(--ink-3);
font-size: 12.5px;
}
.totals b {
display: inline-block;
margin-top: 8px;
color: var(--ink);
font-family: "DIN Alternate", "Helvetica Neue", sans-serif;
font-size: 24px;
font-weight: 700;
letter-spacing: 0;
line-height: 1;
}
.totals em {
margin-left: 3px;
color: var(--ink-3);
font-style: normal;
font-size: 12px;
}
@media (max-width: 1240px) {
.g5 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.g4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.g6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.pagehead {
align-items: flex-start;
flex-direction: column;
}
.g4,
.g5,
.g6,
.totals {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.dashboard-v2 {
padding: 18px 16px 36px;
}
.g4,
.g5,
.g6,
.totals {
grid-template-columns: 1fr;
}
.revenue-meta {
flex-wrap: wrap;
}
.totals div {
border-right: 0;
border-bottom: 1px solid var(--border-soft);
}
.totals div:last-child {
border-bottom: 0;
}
}
`}</style>
</div>
);
}