7f5dde5c2f
改了什么:数据大盘总收入/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>
1516 lines
52 KiB
TypeScript
1516 lines
52 KiB
TypeScript
'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>
|
||
);
|
||
}
|