Files
shaguabijia-admin-web/src/app/(main)/dashboard/page.tsx
T

1664 lines
58 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, DatePicker, Select, Spin, Tag, Tooltip } from 'antd';
import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons';
import dayjs, { type 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) {
// 次日留存口径(2026-07-05):每天取前一日新增用户,统计其当日活跃比例;单日窗口即「前日新增的昨日留存」。
if (period === '7d') return '近 7 日新增次日留存';
if (period === '30d') return '近 30 日新增次日留存';
return '前日用户新增留存';
}
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
if (!report) return null;
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType];
// 优先用后端全量小计 type_stats(items 受 limit 分页截断,>1000 行的区间按 items 现算会漏旧数据)
const stat = report.type_stats?.[adType];
if (stat) return { impressions: stat.impressions, revenue_yuan: stat.revenue_yuan };
const matched = report.items.filter((it) => it.ad_type === adType);
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 adSceneSummary(report: AdRevenueReport | null, feedScene: 'coupon' | 'comparison') {
if (!report) return null;
// 优先用后端全量小计 scene_stats:2026-07-02 起 items 不再含信息流逐条展示行(唯一带收益+场景的行),
// 按 items 现算恒为 0;scene_stats 在全量 events 上聚合且不受分页截断。旧后端无此字段时回退 items 现算。
const stat = report.scene_stats?.[feedScene];
if (stat) return { impressions: stat.impressions, revenue_yuan: stat.revenue_yuan };
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 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 262" width="100%" height="240" 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="10" fontWeight="600">
{points.map((p, idx) => (
<text
key={p.date}
x={xFor(idx)}
y="236"
textAnchor="end"
transform={`rotate(-45 ${xFor(idx)} 236)`}
>
{dayjs(p.date).format('MM-DD')}
</text>
))}
</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 [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
const [datePickerOpen, setDatePickerOpen] = useState(false);
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 [couponP95Ms, setCouponP95Ms] = useState<number | null>(null);
const range = useMemo(() => {
if (!customRange) return periodRange(period);
const start = customRange[0].startOf('day');
const end = customRange[1].startOf('day');
return {
start,
end,
label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}`,
days: end.diff(start, 'day') + 1,
};
}, [period, customRange]);
const previousRange = useMemo(() => previousPeriodRange(range), [range]);
const periodData = data?.period ?? null;
const previousPeriodData = previousData?.period ?? null;
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 comparisonTotal = periodData?.comparison.total ?? null;
const comparisonCompleted = periodData?.comparison.completed ?? null;
const comparisonSuccess = periodData?.comparison.success ?? null;
const comparisonSuccessRateDenominator =
periodData == null || periodData.comparison.cancelled == null
? null
: periodData.comparison.total - periodData.comparison.cancelled;
const comparisonMedianMs = periodData?.comparison.median_duration_ms ?? null;
const comparisonP95Ms = periodData?.comparison.p95_duration_ms ?? null;
const tokenCostYuan = periodData?.comparison.token_cost_total_yuan ?? 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 periodActiveUsers = periodData?.users.active ?? null;
const arpuYuan =
totalRevenueYuan == null || periodActiveUsers == null || periodActiveUsers <= 0
? null
: totalRevenueYuan / periodActiveUsers;
const coinCostYuan =
periodData?.coins.granted_total == null ? null : periodData.coins.granted_total / 10000;
const grossArpuYuan =
totalRevenueYuan == null ||
tokenCostYuan == null ||
coinCostYuan == null ||
periodActiveUsers == null ||
periodActiveUsers <= 0
? null
: (totalRevenueYuan - tokenCostYuan - coinCostYuan) / periodActiveUsers;
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 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.median_duration_ms,
previousPeriodData?.comparison.median_duration_ms,
);
// 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--')
const couponPeriod = periodData?.coupon ?? null;
const previousCouponPeriod = previousPeriodData?.coupon ?? null;
const couponStartedDelta = percentDelta(couponPeriod?.started, previousCouponPeriod?.started);
const couponSuccessRateValue =
couponPeriod?.success_rate == null ? '--' : (couponPeriod.success_rate * 100).toFixed(1);
const couponSuccessRateDelta = pointDelta(couponPeriod?.success_rate, previousCouponPeriod?.success_rate);
const couponPointRateValue =
couponPeriod?.point_success_rate == null ? '--' : (couponPeriod.point_success_rate * 100).toFixed(1);
const couponPointRateDelta = pointDelta(
couponPeriod?.point_success_rate,
previousCouponPeriod?.point_success_rate,
);
const couponMedianDelta = durationDelta(
couponPeriod?.median_elapsed_ms,
previousCouponPeriod?.median_elapsed_ms,
);
const averageSavedDelta = moneyDeltaCents(
periodData?.comparison.average_saved_cents,
previousPeriodData?.comparison.average_saved_cents,
);
const totalRevenueDelta = percentDelta(totalRevenueYuan, previousTotalRevenueYuan);
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;
api
.get<{ summary: { p95_ms: number | null } }>('/admin/api/coupon-data', {
params: {
date_from: range.start.format('YYYY-MM-DD'),
date_to: range.end.format('YYYY-MM-DD'),
// 与数据大盘其余卡片一致,不限定应用环境。
status: ['started', 'completed', 'failed', 'abandoned'],
granularity: 'day',
limit: 1,
offset: 0,
},
paramsSerializer: { indexes: null },
})
.then(({ data: couponData }) => {
if (alive) setCouponP95Ms(couponData.summary.p95_ms);
})
.catch(() => {
if (alive) setCouponP95Ms(null);
});
return () => {
alive = false;
};
}, [range.start, range.end, refreshKey]);
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={!customRange && period === it.key ? 'on' : undefined}
onClick={() => {
setPeriod(it.key);
setCustomRange(null);
}}
>
{it.label}
</button>
))}
</div>
<DatePicker.RangePicker
className="dashboard-date-picker"
value={[range.start, range.end]}
format="YYYY-MM-DD"
allowClear={false}
open={datePickerOpen}
disabledDate={(date) => date.isAfter(dayjs().subtract(1, 'day'), 'day')}
suffixIcon={<CalendarOutlined />}
onClick={() => setDatePickerOpen(true)}
onOpenChange={setDatePickerOpen}
onChange={(value) => {
if (value?.[0] && value[1]) {
setCustomRange([value[0], value[1]]);
setDatePickerOpen(false);
}
}}
/>
<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。"
/>
<StatCard
title="新增用户"
value={fmtInt(periodData?.users.new)}
delta={newUserDelta.value}
deltaTone={newUserDelta.tone}
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
/>
<StatCard
title={retentionTitle(period)}
value={retentionValue}
unit="%"
delta={retentionDelta.value}
deltaTone={retentionDelta.tone}
hint={periodData?.users.retention_note ?? '暂无留存数据'}
/>
<StatCard
title="比价次数"
value={fmtInt(comparisonTotal)}
delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone}
/>
</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 g4">
<StatCard
title="比价发起次数"
value={fmtInt(comparisonTotal)}
unit="次"
delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone}
/>
<StatCard
title="比价完成次数"
value={fmtInt(comparisonCompleted)}
unit="次"
hint="成功次数 + 失败次数,不包含中途退出。"
/>
<StatCard
title="比价成功次数"
value={fmtInt(comparisonSuccess)}
unit="次"
delta={comparisonSuccessDelta.value}
deltaTone={comparisonSuccessDelta.tone}
/>
<StatCard
title="比价成功率"
value={pct(compareSuccessRate)}
hint={`成功次数 ÷(比价发起次数-中途退出次数);当前 ${fmtInt(comparisonSuccess)} ÷ ${fmtInt(comparisonSuccessRateDenominator)}`}
/>
<StatCard
title="下单次数"
value={fmtInt(periodData?.comparison.ordered)}
unit="次"
delta={comparisonOrderedDelta.value}
deltaTone={comparisonOrderedDelta.tone}
hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。"
/>
<StatCard
title="耗时中位数"
value={fmtMsAsSeconds(comparisonMedianMs)}
unit="秒"
delta={durationDeltaInfo.value}
deltaTone={durationDeltaInfo.tone}
hint="只统计成功和失败的比价记录,中途退出不参与耗时统计。"
/>
<StatCard
title="耗时 95% 分位数"
value={fmtMsAsSeconds(comparisonP95Ms)}
unit="秒"
hint="只统计成功和失败的比价记录;95% 的有效比价耗时不超过该值。"
/>
<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></span>
</div>
<div className="grid g5">
<StatCard
title="领券发起数"
value={fmtInt(couponPeriod?.started)}
unit="次"
delta={couponStartedDelta.value}
deltaTone={couponStartedDelta.tone}
/>
<StatCard
title="领券成功率"
value={couponSuccessRateValue}
unit="%"
delta={couponSuccessRateDelta.value}
deltaTone={couponSuccessRateDelta.tone}
hint={`全部点位领成功的次数 / 领券发起数;本期 ${fmtInt(couponPeriod?.all_success)} / ${fmtInt(
couponPeriod?.started,
)} 次。点位成功口径含「今日已领过」。`}
/>
<StatCard
title="点位成功率"
value={couponPointRateValue}
unit="%"
delta={couponPointRateDelta.value}
deltaTone={couponPointRateDelta.tone}
hint={`成功点位数 / (领券发起数 × 每次应领点位数);中途退出未跑到的点位计入分母视为失败。本期成功点位 ${fmtInt(
couponPeriod?.point_success,
)} 个,每次应领 ${fmtInt(couponPeriod?.points_per_session)} 个(按本期完成场自动校准)。`}
/>
<StatCard
title="耗时中位数"
value={fmtMsAsSeconds(couponPeriod?.median_elapsed_ms)}
unit="秒"
delta={couponMedianDelta.value}
deltaTone={couponMedianDelta.tone}
hint="仅统计「完成」的领券(同领券数据页口径),中位数比均值更抗中途暂停等待的长尾。"
/>
<StatCard
title="耗时 95% 分位数"
value={fmtMsAsSeconds(couponP95Ms)}
unit="秒"
hint="仅统计「完成」的领券;95% 的完成场次耗时不超过该值。"
/>
</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 g5">
<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="ARPU"
value={adLoading ? '--' : fmtYuan(arpuYuan)}
meta={[
{ label: '口径', value: '总收入 / 区间活跃用户' },
{ label: '分母', value: periodActiveUsers == null ? '--' : `${fmtInt(periodActiveUsers)}` },
]}
/>
<RevenueCard
title="TOKEN 成本"
value={fmtYuan(tokenCostYuan)}
meta={[
{ label: '口径', value: '区间比价记录 LLM 实际成本合计' },
]}
/>
<RevenueCard
title="发放金币成本"
value={fmtYuan(coinCostYuan)}
meta={[
{ label: '口径', value: '本期发放金币 / 10000' },
]}
/>
<RevenueCard
title="毛利 ARPU"
value={fmtYuan(grossArpuYuan)}
meta={[
{ label: '口径', value: '(总收入-TOKEN 成本-金币成本)/ DAU' },
]}
accent={grossArpuYuan != null && grossArpuYuan < 0 ? 'red' : 'green'}
/>
</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)}
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) / 10000)}</b>
<b>{fmtCents(periodData?.cash.withdraw_success_cents)}</b>
<b>{fmtCoinAmount(data.coins.granted_total)}</b>
<b>{fmtCents(data.cash.withdraw_success_cents)}</b>/
/ 4
</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;
}
.dashboard-date-picker {
min-width: 240px;
cursor: pointer;
}
.dashboard-date-picker:hover {
border-color: var(--brand-blue);
}
.dashboard-date-picker input {
cursor: pointer;
}
.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>
);
}