diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index 3fe8427..6fc1804 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -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} + {CMP_LABEL} + + ); +} + +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) { + 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) { 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 {children}; } @@ -107,8 +191,8 @@ function DeltaBadge({ children, tone = 'muted', }: { - children: React.ReactNode; - tone?: 'muted' | 'up' | 'down'; + children: ReactNode; + tone?: DeltaTone; }) { return {children}; } @@ -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 (
@@ -168,7 +256,13 @@ function StatCard({ {value} {unit && value !== '--' && {unit}}
- {spark && } + {spark && ( + + )} {delta ? {delta} : status && {status}} @@ -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 (
{title}
- {delta && {delta}} + {delta && {delta}}
{value}
{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('yesterday'); const [data, setData] = useState(null); + const [previousData, setPreviousData] = useState(null); const [adReport, setAdReport] = useState(null); + const [previousAdReport, setPreviousAdReport] = useState(null); const [loading, setLoading] = useState(true); const [adLoading, setAdLoading] = useState(false); const [overviewError, setOverviewError] = useState(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('/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('/admin/api/stats/overview', { params: currentParams }), + api.get('/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('/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('/admin/api/ad-revenue-report', { params: currentParams }), + api.get('/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 ; @@ -464,7 +613,7 @@ export default function DashboardPage() { return ; } - 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() {

核心趋势

先看用户、留存与比价活跃度
-
- +
@@ -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} />
@@ -571,23 +721,43 @@ export default function DashboardPage() { 发起、成功、下单与效率
- - + + - +
@@ -597,7 +767,7 @@ export default function DashboardPage() {

变现

{cpsAvailable - ? (adError ? '广告收益接口当前后端不可用,美团 CPS 已接入' : '广告收益与美团 CPS 已接入') + ? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入') : (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')} @@ -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() { 0 && adReport?.total_revenue_yuan != null + ? `${((adReport.total_revenue_yuan / totalRevenueYuan) * 100).toFixed(1)}%` + : '--', + }, + ]} /> 0 && totalCpsCommissionCents != null + ? `${(((totalCpsCommissionCents / 100) / totalRevenueYuan) * 100).toFixed(1)}%` + : '--', + }, + ]} />
CPS 收益 -
- - + +
广告收益
@@ -705,32 +905,58 @@ export default function DashboardPage() {

金币经济

风控重点 - 发放是账面负债,提现是真实现金成本 + 发放 = 平台负债,提现 = 真实现金成本
- + + - + -
- 本期已发放 {fmtInt(periodData?.coins.granted_total)} 金币,成功提现 {fmtCents(periodData?.cash.withdraw_success_cents)}; - 累计已发放 {fmtInt(data.coins.granted_total)} 金币,累计成功提现 {fmtCents(data.cash.withdraw_success_cents)}。 - 金币分类已按产品口径归类;邀请、后台手动加金币、福利页广告、无法判断来源的广告不进入上述分类。 + ⚠️ +
+ 本期 4 项奖励合计发放 {fmtCoinAmount(periodData?.coins.granted_total)} 金币 + (账面负债 ≈ {fmtYuan((periodData?.coins.granted_total ?? 0) / 1000)}), + 实际提现兑付 {fmtCents(periodData?.cash.withdraw_success_cents)}。 + 累计已发放 {fmtCoinAmount(data.coins.granted_total)} 金币但累计真实提现仅 + {fmtCents(data.cash.withdraw_success_cents)},发放与现金兑付背离属正常(金币沉淀/过期); + 需盯紧各来源占比突变与异常领取,防薅羊毛。 +
@@ -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; diff --git a/src/lib/types.ts b/src/lib/types.ts index c3a95c9..05e79a8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; }; }