Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f4758e81d | |||
| 82ffc73be5 |
@@ -0,0 +1,749 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Divider,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popover,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
FallOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { formatUtcTime } from '@/lib/format';
|
||||||
|
import type {
|
||||||
|
AdRevenueDaily,
|
||||||
|
AdRevenueRecord,
|
||||||
|
AdRevenueReport,
|
||||||
|
AdRevenueRow,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
// 广告类型标签
|
||||||
|
const TYPE_TAG: Record<string, { color: string; label: string }> = {
|
||||||
|
reward_video: { color: 'blue', label: '激励视频' },
|
||||||
|
feed: { color: 'purple', label: '信息流' },
|
||||||
|
draw: { color: 'geekblue', label: 'Draw 信息流' },
|
||||||
|
withdrawal_video: { color: 'gold', label: '提现激励视频' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 我们的应用环境标签
|
||||||
|
const APP_TAG: Record<string, { color: string; label: string }> = {
|
||||||
|
prod: { color: 'green', label: '傻瓜比价(正式)' },
|
||||||
|
test: { color: 'default', label: '测试应用' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限)
|
||||||
|
const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||||||
|
granted: { color: 'green', label: '已发' },
|
||||||
|
capped: { color: 'orange', label: '次数超限' },
|
||||||
|
ecpm_missing: { color: 'red', label: '缺 eCPM' },
|
||||||
|
too_short: { color: 'gold', label: '未满10秒' },
|
||||||
|
closed_early: { color: 'default', label: '提前关闭' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtFactorRange = (a: number | null, b: number | null) => {
|
||||||
|
if (a == null) return '-';
|
||||||
|
return a === b || b == null ? String(a) : `${a}→${b}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtIndexRange = (a: number | null, b: number | null) => {
|
||||||
|
if (a == null) return '-';
|
||||||
|
return a === b ? String(a) : `${a}–${b}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
|
||||||
|
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
|
||||||
|
const COIN_PER_YUAN = 10000;
|
||||||
|
// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE
|
||||||
|
const ECPM_FACTOR_ROWS = [
|
||||||
|
{ key: '4', range: '> 400', factor: 0.6 },
|
||||||
|
{ key: '3', range: '201 – 400', factor: 0.4 },
|
||||||
|
{ key: '2', range: '101 – 200', factor: 0.3 },
|
||||||
|
{ key: '1', range: '0 – 100', factor: 0.1 },
|
||||||
|
];
|
||||||
|
// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE
|
||||||
|
const LT_FACTOR_ROWS = [
|
||||||
|
{ key: '1', lt: '第 1 条', factor: 2.0 },
|
||||||
|
{ key: '2', lt: '第 2 条', factor: 1.5 },
|
||||||
|
{ key: '3', lt: '第 3 条', factor: 1.3 },
|
||||||
|
{ key: '4', lt: '4 – 10 条', factor: 1.1 },
|
||||||
|
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
|
||||||
|
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
|
||||||
|
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (s: string) => {
|
||||||
|
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||||
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
|
||||||
|
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
|
||||||
|
{ title: '份数', dataIndex: 'units', width: 60 },
|
||||||
|
{
|
||||||
|
title: 'LT累计条数',
|
||||||
|
key: 'lt_index',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '因子2',
|
||||||
|
key: 'lt_factor',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
|
||||||
|
},
|
||||||
|
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
|
||||||
|
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
|
||||||
|
{
|
||||||
|
title: '一致',
|
||||||
|
dataIndex: 'matched',
|
||||||
|
width: 80,
|
||||||
|
render: (m: boolean) => (m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
|
||||||
|
const CHART_BAR = '#69b1ff';
|
||||||
|
const CHART_LINE = '#fa8c16';
|
||||||
|
|
||||||
|
interface TrendPoint {
|
||||||
|
label: string; // x 轴刻度文案(小时数 / MM-DD)
|
||||||
|
tip: string; // hover 原生 tooltip 全文
|
||||||
|
impressions: number;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告)
|
||||||
|
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
|
||||||
|
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
|
||||||
|
byHour[r.hour].impressions += r.impressions;
|
||||||
|
byHour[r.hour].revenue += r.revenue_yuan;
|
||||||
|
}
|
||||||
|
return byHour.map((b) => ({
|
||||||
|
label: String(b.hour),
|
||||||
|
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)} 元`,
|
||||||
|
impressions: b.impressions,
|
||||||
|
revenue: b.revenue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
|
||||||
|
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
|
||||||
|
const map = new Map(daily.map((d) => [d.date, d]));
|
||||||
|
const out: TrendPoint[] = [];
|
||||||
|
let cur = dayjs(dateFrom);
|
||||||
|
let guard = 0;
|
||||||
|
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
|
||||||
|
const ds = cur.format('YYYY-MM-DD');
|
||||||
|
const d = map.get(ds);
|
||||||
|
const impressions = d?.impressions ?? 0;
|
||||||
|
const revenue = d?.revenue_yuan ?? 0;
|
||||||
|
out.push({
|
||||||
|
label: ds.slice(5),
|
||||||
|
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)} 元`,
|
||||||
|
impressions,
|
||||||
|
revenue,
|
||||||
|
});
|
||||||
|
cur = cur.add(1, 'day');
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||||
|
const n = points.length;
|
||||||
|
const maxImp = Math.max(1, ...points.map((p) => p.impressions));
|
||||||
|
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
|
||||||
|
|
||||||
|
const W = 960;
|
||||||
|
const H = 280;
|
||||||
|
const padL = 48;
|
||||||
|
const padR = 56;
|
||||||
|
const padT = 16;
|
||||||
|
const padB = 32;
|
||||||
|
const plotW = W - padL - padR;
|
||||||
|
const plotH = H - padT - padB;
|
||||||
|
const step = plotW / Math.max(1, n);
|
||||||
|
const barW = Math.min(28, step * 0.55);
|
||||||
|
const yBase = padT + plotH;
|
||||||
|
|
||||||
|
const barX = (i: number) => padL + i * step + (step - barW) / 2;
|
||||||
|
const cx = (i: number) => padL + i * step + step / 2;
|
||||||
|
const impH = (v: number) => (v / maxImp) * plotH;
|
||||||
|
const revY = (v: number) => yBase - (v / maxRev) * plotH;
|
||||||
|
const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' ');
|
||||||
|
const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
|
||||||
|
const y = yBase - t * plotH;
|
||||||
|
return (
|
||||||
|
<g key={t}>
|
||||||
|
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
|
||||||
|
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR}>
|
||||||
|
{Math.round(maxImp * t)}
|
||||||
|
</text>
|
||||||
|
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
|
||||||
|
{(maxRev * t).toFixed(2)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={barX(i)}
|
||||||
|
y={yBase - impH(p.impressions)}
|
||||||
|
width={barW}
|
||||||
|
height={impH(p.impressions)}
|
||||||
|
fill={CHART_BAR}
|
||||||
|
rx={2}
|
||||||
|
>
|
||||||
|
<title>{p.tip}</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
|
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle key={i} cx={cx(i)} cy={revY(p.revenue)} r={2.5} fill={CHART_LINE}>
|
||||||
|
<title>{p.tip}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
{points.map((p, i) =>
|
||||||
|
i % labelEvery === 0 || i === n - 1 ? (
|
||||||
|
<text key={i} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广告收益报表页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
|
||||||
|
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
|
||||||
|
// 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。
|
||||||
|
export default function AdRevenueReportPage() {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
||||||
|
const [userId, setUserId] = useState<number | null>(null);
|
||||||
|
const [adType, setAdType] = useState<string | undefined>();
|
||||||
|
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||||
|
const [limit, setLimit] = useState<number>(500);
|
||||||
|
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
|
||||||
|
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
|
||||||
|
const [data, setData] = useState<AdRevenueReport | null>(null);
|
||||||
|
const [queriedLimit, setQueriedLimit] = useState<number>(500);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formulaOpen, setFormulaOpen] = useState(false);
|
||||||
|
|
||||||
|
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
|
||||||
|
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const from = range[0].format('YYYY-MM-DD');
|
||||||
|
const to = range[1].format('YYYY-MM-DD');
|
||||||
|
const multiDay = from !== to;
|
||||||
|
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||||
|
params: {
|
||||||
|
date_from: from,
|
||||||
|
date_to: to,
|
||||||
|
user_id: userId ?? undefined,
|
||||||
|
ad_type: adType ?? undefined,
|
||||||
|
granularity: gran,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setData(res.data);
|
||||||
|
setQueriedLimit(limit);
|
||||||
|
setQueriedGranularity(gran);
|
||||||
|
setQueriedMultiDay(multiDay);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range, userId, adType, granularity, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// 仅首次自动拉今天;之后由「查询」按钮触发
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns: ColumnsType<AdRevenueRow> = [
|
||||||
|
...(queriedMultiDay
|
||||||
|
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
||||||
|
: []),
|
||||||
|
{ title: '时间', dataIndex: 'created_at', width: 165, render: (v: string) => formatUtcTime(v) },
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'user_phone',
|
||||||
|
width: 150,
|
||||||
|
render: (phone: string | null, r: AdRevenueRow) =>
|
||||||
|
phone ? (
|
||||||
|
<span>
|
||||||
|
{phone}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
|
||||||
|
#{r.user_id}
|
||||||
|
</Typography.Text>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">#{r.user_id}(无手机号)</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '广告类型',
|
||||||
|
dataIndex: 'ad_type',
|
||||||
|
width: 110,
|
||||||
|
render: (s: string) => {
|
||||||
|
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
|
||||||
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源应用',
|
||||||
|
dataIndex: 'app_env',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string | null) => {
|
||||||
|
if (!v) return <Typography.Text type="secondary">未知</Typography.Text>;
|
||||||
|
const t = APP_TAG[v] ?? { color: 'default', label: v };
|
||||||
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '广告位ID',
|
||||||
|
dataIndex: 'our_code_id',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string | null) =>
|
||||||
|
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'eCPM(分)',
|
||||||
|
dataIndex: 'ecpm',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string | null) => v ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '预估收益(元)',
|
||||||
|
dataIndex: 'revenue_yuan',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '发奖状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s: string | null) => {
|
||||||
|
if (!s) return <Tag>仅展示</Tag>;
|
||||||
|
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||||
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '应发金币',
|
||||||
|
dataIndex: 'expected_coin',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number, r: AdRevenueRow) =>
|
||||||
|
r.has_reward ? <b>{v}</b> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '实发金币',
|
||||||
|
dataIndex: 'actual_coin',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number, r: AdRevenueRow) =>
|
||||||
|
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '一致',
|
||||||
|
dataIndex: 'matched',
|
||||||
|
width: 70,
|
||||||
|
align: 'center',
|
||||||
|
render: (m: boolean, r: AdRevenueRow) =>
|
||||||
|
r.has_reward ? (
|
||||||
|
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">-</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '底层 ADN',
|
||||||
|
dataIndex: 'adn',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | null) =>
|
||||||
|
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
|
||||||
|
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
|
||||||
|
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
|
||||||
|
const derived = data
|
||||||
|
? {
|
||||||
|
avgEcpm: data.total_impressions
|
||||||
|
? (data.total_revenue_yuan / data.total_impressions) * 1000
|
||||||
|
: 0,
|
||||||
|
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
|
||||||
|
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
|
||||||
|
payoutRatioPct:
|
||||||
|
data.total_revenue_yuan > 0
|
||||||
|
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
|
||||||
|
: null,
|
||||||
|
coinGap: data.total_expected_coin - data.total_actual_coin,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ margin: 0 }}>收益报表</h2>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<QuestionCircleOutlined />}
|
||||||
|
onClick={() => setFormulaOpen(true)}
|
||||||
|
>
|
||||||
|
金币计算规则
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
placement="bottomLeft"
|
||||||
|
title="收益口径说明"
|
||||||
|
content={
|
||||||
|
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
|
||||||
|
「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000
|
||||||
|
累加),<b>只要广告展示就计入、不论是否看完发奖</b>;穿山甲会过滤无效/过短曝光,故预估值可能偏高,
|
||||||
|
<b>实际收益一律以穿山甲后台结算为准</b>。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" icon={<InfoCircleOutlined />}>
|
||||||
|
收益口径
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space wrap size="middle" align="center">
|
||||||
|
<Space size={6}>
|
||||||
|
<Typography.Text type="secondary">日期</Typography.Text>
|
||||||
|
<RangePicker
|
||||||
|
value={range}
|
||||||
|
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
|
||||||
|
allowClear={false}
|
||||||
|
presets={[
|
||||||
|
{ label: '今天', value: [dayjs(), dayjs()] },
|
||||||
|
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
|
||||||
|
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space size={6}>
|
||||||
|
<Typography.Text type="secondary">用户</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
placeholder="可空"
|
||||||
|
value={userId ?? undefined}
|
||||||
|
onChange={(v) => setUserId(v ?? null)}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space size={6}>
|
||||||
|
<Typography.Text type="secondary">类型</Typography.Text>
|
||||||
|
<Select
|
||||||
|
placeholder="全部"
|
||||||
|
value={adType}
|
||||||
|
onChange={setAdType}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 150 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'reward_video', label: '激励视频' },
|
||||||
|
{ value: 'feed', label: '信息流' },
|
||||||
|
{ value: 'draw', label: 'Draw 信息流' },
|
||||||
|
{ value: 'withdrawal_video', label: '提现激励视频' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space size={6}>
|
||||||
|
<Typography.Text type="secondary">粒度</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={rangeMultiDay ? 'day' : granularity}
|
||||||
|
onChange={setGranularity}
|
||||||
|
disabled={rangeMultiDay}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
|
||||||
|
options={[
|
||||||
|
{ value: 'day', label: '按天' },
|
||||||
|
{ value: 'hour', label: '按小时' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space size={6}>
|
||||||
|
<Typography.Text type="secondary">条数</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={limit}
|
||||||
|
onChange={setLimit}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" onClick={load} loading={loading}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{data && derived && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
广告展示与收益
|
||||||
|
</Typography.Text>
|
||||||
|
</Divider>
|
||||||
|
<Row gutter={[16, 12]}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="展示条数合计" value={data.total_impressions} />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="广告事件数" value={data.total} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
发奖与对账
|
||||||
|
</Typography.Text>
|
||||||
|
</Divider>
|
||||||
|
<Row gutter={[16, 12]}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="应发金币合计" value={data.total_expected_coin} />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="实发金币合计" value={data.total_actual_coin} />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="预估毛利(元)"
|
||||||
|
value={derived.grossProfit}
|
||||||
|
precision={4}
|
||||||
|
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
||||||
|
valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
发奖占收益{' '}
|
||||||
|
{derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`}
|
||||||
|
</Typography.Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
|
||||||
|
{data.mismatch_count > 0 ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message={`共 ${data.total} 条广告事件,其中 ${data.mismatch_count} 条应发≠实发(✗);应发−实发 合计 ${
|
||||||
|
derived && derived.coinGap >= 0 ? '+' : ''
|
||||||
|
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert type="success" showIcon message={`共 ${data.total} 条广告事件,应发与实发全部一致。`} />
|
||||||
|
)}
|
||||||
|
{data.truncated && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={`明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/类型/日期缩小范围,或调大「条数」。合计数字不受影响。`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data &&
|
||||||
|
(queriedMultiDay
|
||||||
|
? (data.daily?.length ?? 0) > 0
|
||||||
|
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
extra={
|
||||||
|
<Space size={16}>
|
||||||
|
<span style={{ fontSize: 12, color: '#666' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: CHART_BAR,
|
||||||
|
borderRadius: 2,
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
展示条数
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: '#666' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 14,
|
||||||
|
borderTop: `2px solid ${CHART_LINE}`,
|
||||||
|
marginRight: 4,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
预估收益(元)
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
|
||||||
|
{!queriedMultiDay && data.truncated && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{queriedMultiDay ? (
|
||||||
|
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
|
||||||
|
) : (
|
||||||
|
<TrendChart points={aggregateHourly(data.items)} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="event_key"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
||||||
|
expandable={{
|
||||||
|
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
|
||||||
|
rowExpandable: (r) => r.reward_detail != null,
|
||||||
|
expandedRowRender: (r) =>
|
||||||
|
r.reward_detail ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>金币复算明细</Typography.Text>{' '}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
(本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin})
|
||||||
|
</Typography.Text>
|
||||||
|
<Table<AdRevenueRecord>
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
rowKey="record_id"
|
||||||
|
columns={DETAIL_COLUMNS}
|
||||||
|
dataSource={[r.reward_detail]}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="金币计算规则"
|
||||||
|
open={formulaOpen}
|
||||||
|
onCancel={() => setFormulaOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={680}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
<b>单次奖励(元)</b> =(单次 eCPM<sub>元</sub> ÷ 1000)× <b>因子1</b>(eCPM 档)× <b>因子2</b>(LT 累计条数)
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
· eCPM 取自穿山甲 SDK <Typography.Text code>getEcpm()</Typography.Text> 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。
|
||||||
|
<br />· 金币汇率:<b>1 元 = {COIN_PER_YUAN.toLocaleString()} 金币</b>,最终四舍五入取整。
|
||||||
|
<br />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。
|
||||||
|
<br />· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>因子1 ·按 eCPM(元/千次)判档</Typography.Text>
|
||||||
|
<Table
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={ECPM_FACTOR_ROWS}
|
||||||
|
columns={[
|
||||||
|
{ title: 'eCPM 范围(元)', dataIndex: 'range' },
|
||||||
|
{ title: '因子1', dataIndex: 'factor', width: 80 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>因子2 ·按账号 LT 累计条数</Typography.Text>
|
||||||
|
<Table
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={LT_FACTOR_ROWS}
|
||||||
|
columns={[
|
||||||
|
{ title: 'LT 累计条数', dataIndex: 'lt' },
|
||||||
|
{ title: '因子2', dataIndex: 'factor', width: 80 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginTop: 12, marginBottom: 0 }}>
|
||||||
|
示例:eCPM = 300 元/千次、账号第 1 条 → 0.3 × 0.4 × 2.0 = 0.24 元 = 2,400 金币。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.row-mismatch > td {
|
||||||
|
background: #fff1f0 !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,259 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import { Alert, App, Button, Card, Form, Input, Switch } from 'antd';
|
||||||
import {
|
|
||||||
App,
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
DatePicker,
|
|
||||||
Divider,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Modal,
|
|
||||||
Popover,
|
|
||||||
Row,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Statistic,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
FallOutlined,
|
|
||||||
InfoCircleOutlined,
|
|
||||||
QuestionCircleOutlined,
|
|
||||||
RiseOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { formatUtcTime } from '@/lib/format';
|
|
||||||
import type {
|
|
||||||
AdRevenueDaily,
|
|
||||||
AdRevenueRecord,
|
|
||||||
AdRevenueReport,
|
|
||||||
AdRevenueRow,
|
|
||||||
} from '@/lib/types';
|
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
|
|
||||||
// 广告类型标签
|
|
||||||
const TYPE_TAG: Record<string, { color: string; label: string }> = {
|
|
||||||
reward_video: { color: 'blue', label: '激励视频' },
|
|
||||||
feed: { color: 'purple', label: '信息流' },
|
|
||||||
draw: { color: 'geekblue', label: 'Draw 信息流' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 我们的应用环境标签
|
|
||||||
const APP_TAG: Record<string, { color: string; label: string }> = {
|
|
||||||
prod: { color: 'green', label: '傻瓜比价(正式)' },
|
|
||||||
test: { color: 'default', label: '测试应用' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限)
|
|
||||||
const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
|
||||||
granted: { color: 'green', label: '已发' },
|
|
||||||
capped: { color: 'orange', label: '次数超限' },
|
|
||||||
ecpm_missing: { color: 'red', label: '缺 eCPM' },
|
|
||||||
too_short: { color: 'gold', label: '未满10秒' },
|
|
||||||
closed_early: { color: 'default', label: '提前关闭' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmtFactorRange = (a: number | null, b: number | null) => {
|
|
||||||
if (a == null) return '-';
|
|
||||||
return a === b || b == null ? String(a) : `${a}→${b}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmtIndexRange = (a: number | null, b: number | null) => {
|
|
||||||
if (a == null) return '-';
|
|
||||||
return a === b ? String(a) : `${a}–${b}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
|
|
||||||
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
|
|
||||||
const COIN_PER_YUAN = 10000;
|
|
||||||
// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE
|
|
||||||
const ECPM_FACTOR_ROWS = [
|
|
||||||
{ key: '4', range: '> 400', factor: 0.6 },
|
|
||||||
{ key: '3', range: '201 – 400', factor: 0.4 },
|
|
||||||
{ key: '2', range: '101 – 200', factor: 0.3 },
|
|
||||||
{ key: '1', range: '0 – 100', factor: 0.1 },
|
|
||||||
];
|
|
||||||
// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE
|
|
||||||
const LT_FACTOR_ROWS = [
|
|
||||||
{ key: '1', lt: '第 1 条', factor: 2.0 },
|
|
||||||
{ key: '2', lt: '第 2 条', factor: 1.5 },
|
|
||||||
{ key: '3', lt: '第 3 条', factor: 1.3 },
|
|
||||||
{ key: '4', lt: '4 – 10 条', factor: 1.1 },
|
|
||||||
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
|
|
||||||
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
|
|
||||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 110,
|
|
||||||
render: (s: string) => {
|
|
||||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
|
||||||
return <Tag color={t.color}>{t.label}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
|
|
||||||
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
|
|
||||||
{ title: '份数', dataIndex: 'units', width: 60 },
|
|
||||||
{
|
|
||||||
title: 'LT累计条数',
|
|
||||||
key: 'lt_index',
|
|
||||||
width: 100,
|
|
||||||
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '因子2',
|
|
||||||
key: 'lt_factor',
|
|
||||||
width: 90,
|
|
||||||
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
|
|
||||||
},
|
|
||||||
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
|
|
||||||
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
|
|
||||||
{
|
|
||||||
title: '一致',
|
|
||||||
dataIndex: 'matched',
|
|
||||||
width: 80,
|
|
||||||
render: (m: boolean) => (m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
|
|
||||||
const CHART_BAR = '#69b1ff';
|
|
||||||
const CHART_LINE = '#fa8c16';
|
|
||||||
|
|
||||||
interface TrendPoint {
|
|
||||||
label: string; // x 轴刻度文案(小时数 / MM-DD)
|
|
||||||
tip: string; // hover 原生 tooltip 全文
|
|
||||||
impressions: number;
|
|
||||||
revenue: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告)
|
|
||||||
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
|
|
||||||
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
|
|
||||||
for (const r of rows) {
|
|
||||||
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
|
|
||||||
byHour[r.hour].impressions += r.impressions;
|
|
||||||
byHour[r.hour].revenue += r.revenue_yuan;
|
|
||||||
}
|
|
||||||
return byHour.map((b) => ({
|
|
||||||
label: String(b.hour),
|
|
||||||
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)} 元`,
|
|
||||||
impressions: b.impressions,
|
|
||||||
revenue: b.revenue,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
|
|
||||||
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
|
|
||||||
const map = new Map(daily.map((d) => [d.date, d]));
|
|
||||||
const out: TrendPoint[] = [];
|
|
||||||
let cur = dayjs(dateFrom);
|
|
||||||
let guard = 0;
|
|
||||||
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
|
|
||||||
const ds = cur.format('YYYY-MM-DD');
|
|
||||||
const d = map.get(ds);
|
|
||||||
const impressions = d?.impressions ?? 0;
|
|
||||||
const revenue = d?.revenue_yuan ?? 0;
|
|
||||||
out.push({
|
|
||||||
label: ds.slice(5),
|
|
||||||
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)} 元`,
|
|
||||||
impressions,
|
|
||||||
revenue,
|
|
||||||
});
|
|
||||||
cur = cur.add(1, 'day');
|
|
||||||
guard += 1;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendChart({ points }: { points: TrendPoint[] }) {
|
|
||||||
const n = points.length;
|
|
||||||
const maxImp = Math.max(1, ...points.map((p) => p.impressions));
|
|
||||||
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
|
|
||||||
|
|
||||||
const W = 960;
|
|
||||||
const H = 280;
|
|
||||||
const padL = 48;
|
|
||||||
const padR = 56;
|
|
||||||
const padT = 16;
|
|
||||||
const padB = 32;
|
|
||||||
const plotW = W - padL - padR;
|
|
||||||
const plotH = H - padT - padB;
|
|
||||||
const step = plotW / Math.max(1, n);
|
|
||||||
const barW = Math.min(28, step * 0.55);
|
|
||||||
const yBase = padT + plotH;
|
|
||||||
|
|
||||||
const barX = (i: number) => padL + i * step + (step - barW) / 2;
|
|
||||||
const cx = (i: number) => padL + i * step + step / 2;
|
|
||||||
const impH = (v: number) => (v / maxImp) * plotH;
|
|
||||||
const revY = (v: number) => yBase - (v / maxRev) * plotH;
|
|
||||||
const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' ');
|
|
||||||
const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
|
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
|
|
||||||
const y = yBase - t * plotH;
|
|
||||||
return (
|
|
||||||
<g key={t}>
|
|
||||||
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
|
|
||||||
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR}>
|
|
||||||
{Math.round(maxImp * t)}
|
|
||||||
</text>
|
|
||||||
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
|
|
||||||
{(maxRev * t).toFixed(2)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{points.map((p, i) => (
|
|
||||||
<rect
|
|
||||||
key={i}
|
|
||||||
x={barX(i)}
|
|
||||||
y={yBase - impH(p.impressions)}
|
|
||||||
width={barW}
|
|
||||||
height={impH(p.impressions)}
|
|
||||||
fill={CHART_BAR}
|
|
||||||
rx={2}
|
|
||||||
>
|
|
||||||
<title>{p.tip}</title>
|
|
||||||
</rect>
|
|
||||||
))}
|
|
||||||
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
|
|
||||||
{points.map((p, i) => (
|
|
||||||
<circle key={i} cx={cx(i)} cy={revY(p.revenue)} r={2.5} fill={CHART_LINE}>
|
|
||||||
<title>{p.tip}</title>
|
|
||||||
</circle>
|
|
||||||
))}
|
|
||||||
{points.map((p, i) =>
|
|
||||||
i % labelEvery === 0 || i === n - 1 ? (
|
|
||||||
<text key={i} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
|
|
||||||
{p.label}
|
|
||||||
</text>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
|
|
||||||
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
|
|
||||||
// 广告配置面板:穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
|
// 广告配置面板:穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
|
||||||
// 客户端(发版后)从 /api/v1/platform/ad-config 拉取;此处经 admin /ad-config 读写。
|
// 客户端(发版后)从 /api/v1/platform/ad-config 拉取;此处经 admin /ad-config 读写。
|
||||||
|
// 收益报表在独立的「广告收益」页(/ad-revenue-report)。
|
||||||
interface AdCfg {
|
interface AdCfg {
|
||||||
app_id: string;
|
app_id: string;
|
||||||
reward_code_id: string;
|
reward_code_id: string;
|
||||||
@@ -263,9 +16,10 @@ interface AdCfg {
|
|||||||
reward_enabled: boolean;
|
reward_enabled: boolean;
|
||||||
compare_ad_enabled: boolean;
|
compare_ad_enabled: boolean;
|
||||||
coupon_ad_enabled: boolean;
|
coupon_ad_enabled: boolean;
|
||||||
|
withdrawal_ad_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdConfigPanel() {
|
export default function AdConfigPage() {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -296,7 +50,6 @@ function AdConfigPanel() {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="广告配置(穿山甲)"
|
title="广告配置(穿山甲)"
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" loading={saving} onClick={save}>
|
<Button type="primary" loading={saving} onClick={save}>
|
||||||
保存
|
保存
|
||||||
@@ -336,504 +89,14 @@ function AdConfigPanel() {
|
|||||||
<Form.Item name="coupon_ad_enabled" label="领券广告开关" valuePropName="checked">
|
<Form.Item name="coupon_ad_enabled" label="领券广告开关" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="withdrawal_ad_enabled"
|
||||||
|
label="提现激励视频开关(关 = 客户端直接放行提现,不看视频)"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdRevenuePage() {
|
|
||||||
const { message } = App.useApp();
|
|
||||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
|
||||||
const [userId, setUserId] = useState<number | null>(null);
|
|
||||||
const [adType, setAdType] = useState<string | undefined>();
|
|
||||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
|
||||||
const [limit, setLimit] = useState<number>(500);
|
|
||||||
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
|
|
||||||
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
|
|
||||||
const [data, setData] = useState<AdRevenueReport | null>(null);
|
|
||||||
const [queriedLimit, setQueriedLimit] = useState<number>(500);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [formulaOpen, setFormulaOpen] = useState(false);
|
|
||||||
|
|
||||||
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
|
|
||||||
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
const from = range[0].format('YYYY-MM-DD');
|
|
||||||
const to = range[1].format('YYYY-MM-DD');
|
|
||||||
const multiDay = from !== to;
|
|
||||||
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
|
||||||
params: {
|
|
||||||
date_from: from,
|
|
||||||
date_to: to,
|
|
||||||
user_id: userId ?? undefined,
|
|
||||||
ad_type: adType ?? undefined,
|
|
||||||
granularity: gran,
|
|
||||||
limit,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setData(res.data);
|
|
||||||
setQueriedLimit(limit);
|
|
||||||
setQueriedGranularity(gran);
|
|
||||||
setQueriedMultiDay(multiDay);
|
|
||||||
} catch (e) {
|
|
||||||
message.error(errMsg(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [range, userId, adType, granularity, limit]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
// 仅首次自动拉今天;之后由「查询」按钮触发
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const columns: ColumnsType<AdRevenueRow> = [
|
|
||||||
...(queriedMultiDay
|
|
||||||
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
|
||||||
: []),
|
|
||||||
{ title: '时间', dataIndex: 'created_at', width: 165, render: (v: string) => formatUtcTime(v) },
|
|
||||||
{
|
|
||||||
title: '用户',
|
|
||||||
dataIndex: 'user_phone',
|
|
||||||
width: 150,
|
|
||||||
render: (phone: string | null, r: AdRevenueRow) =>
|
|
||||||
phone ? (
|
|
||||||
<span>
|
|
||||||
{phone}
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
|
|
||||||
#{r.user_id}
|
|
||||||
</Typography.Text>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">#{r.user_id}(无手机号)</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '广告类型',
|
|
||||||
dataIndex: 'ad_type',
|
|
||||||
width: 110,
|
|
||||||
render: (s: string) => {
|
|
||||||
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
|
|
||||||
return <Tag color={t.color}>{t.label}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '来源应用',
|
|
||||||
dataIndex: 'app_env',
|
|
||||||
width: 140,
|
|
||||||
render: (v: string | null) => {
|
|
||||||
if (!v) return <Typography.Text type="secondary">未知</Typography.Text>;
|
|
||||||
const t = APP_TAG[v] ?? { color: 'default', label: v };
|
|
||||||
return <Tag color={t.color}>{t.label}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '广告位ID',
|
|
||||||
dataIndex: 'our_code_id',
|
|
||||||
width: 110,
|
|
||||||
render: (v: string | null) =>
|
|
||||||
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'eCPM(分)',
|
|
||||||
dataIndex: 'ecpm',
|
|
||||||
width: 100,
|
|
||||||
align: 'right',
|
|
||||||
render: (v: string | null) => v ?? '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '预估收益(元)',
|
|
||||||
dataIndex: 'revenue_yuan',
|
|
||||||
width: 110,
|
|
||||||
align: 'right',
|
|
||||||
render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '发奖状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: (s: string | null) => {
|
|
||||||
if (!s) return <Tag>仅展示</Tag>;
|
|
||||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
|
||||||
return <Tag color={t.color}>{t.label}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '应发金币',
|
|
||||||
dataIndex: 'expected_coin',
|
|
||||||
width: 90,
|
|
||||||
align: 'right',
|
|
||||||
render: (v: number, r: AdRevenueRow) =>
|
|
||||||
r.has_reward ? <b>{v}</b> : <Typography.Text type="secondary">-</Typography.Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '实发金币',
|
|
||||||
dataIndex: 'actual_coin',
|
|
||||||
width: 90,
|
|
||||||
align: 'right',
|
|
||||||
render: (v: number, r: AdRevenueRow) =>
|
|
||||||
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '一致',
|
|
||||||
dataIndex: 'matched',
|
|
||||||
width: 70,
|
|
||||||
align: 'center',
|
|
||||||
render: (m: boolean, r: AdRevenueRow) =>
|
|
||||||
r.has_reward ? (
|
|
||||||
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">-</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '底层 ADN',
|
|
||||||
dataIndex: 'adn',
|
|
||||||
width: 100,
|
|
||||||
render: (v: string | null) =>
|
|
||||||
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
|
|
||||||
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
|
|
||||||
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
|
|
||||||
const derived = data
|
|
||||||
? {
|
|
||||||
avgEcpm: data.total_impressions
|
|
||||||
? (data.total_revenue_yuan / data.total_impressions) * 1000
|
|
||||||
: 0,
|
|
||||||
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
|
|
||||||
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
|
|
||||||
payoutRatioPct:
|
|
||||||
data.total_revenue_yuan > 0
|
|
||||||
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
|
|
||||||
: null,
|
|
||||||
coinGap: data.total_expected_coin - data.total_actual_coin,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<AdConfigPanel />
|
|
||||||
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
|
||||||
<h2 style={{ margin: 0 }}>收益报表</h2>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<QuestionCircleOutlined />}
|
|
||||||
onClick={() => setFormulaOpen(true)}
|
|
||||||
>
|
|
||||||
金币计算规则
|
|
||||||
</Button>
|
|
||||||
<Popover
|
|
||||||
placement="bottomLeft"
|
|
||||||
title="收益口径说明"
|
|
||||||
content={
|
|
||||||
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
|
|
||||||
「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000
|
|
||||||
累加),<b>只要广告展示就计入、不论是否看完发奖</b>;穿山甲会过滤无效/过短曝光,故预估值可能偏高,
|
|
||||||
<b>实际收益一律以穿山甲后台结算为准</b>。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button type="link" size="small" icon={<InfoCircleOutlined />}>
|
|
||||||
收益口径
|
|
||||||
</Button>
|
|
||||||
</Popover>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
|
||||||
<Space wrap size="middle" align="center">
|
|
||||||
<Space size={6}>
|
|
||||||
<Typography.Text type="secondary">日期</Typography.Text>
|
|
||||||
<RangePicker
|
|
||||||
value={range}
|
|
||||||
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
|
|
||||||
allowClear={false}
|
|
||||||
presets={[
|
|
||||||
{ label: '今天', value: [dayjs(), dayjs()] },
|
|
||||||
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
|
|
||||||
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Space size={6}>
|
|
||||||
<Typography.Text type="secondary">用户</Typography.Text>
|
|
||||||
<InputNumber
|
|
||||||
placeholder="可空"
|
|
||||||
value={userId ?? undefined}
|
|
||||||
onChange={(v) => setUserId(v ?? null)}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Space size={6}>
|
|
||||||
<Typography.Text type="secondary">类型</Typography.Text>
|
|
||||||
<Select
|
|
||||||
placeholder="全部"
|
|
||||||
value={adType}
|
|
||||||
onChange={setAdType}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 150 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'reward_video', label: '激励视频' },
|
|
||||||
{ value: 'feed', label: '信息流' },
|
|
||||||
{ value: 'draw', label: 'Draw 信息流' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Space size={6}>
|
|
||||||
<Typography.Text type="secondary">粒度</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={rangeMultiDay ? 'day' : granularity}
|
|
||||||
onChange={setGranularity}
|
|
||||||
disabled={rangeMultiDay}
|
|
||||||
style={{ width: 110 }}
|
|
||||||
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
|
|
||||||
options={[
|
|
||||||
{ value: 'day', label: '按天' },
|
|
||||||
{ value: 'hour', label: '按小时' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Space size={6}>
|
|
||||||
<Typography.Text type="secondary">条数</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={limit}
|
|
||||||
onChange={setLimit}
|
|
||||||
style={{ width: 110 }}
|
|
||||||
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条` }))}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Button type="primary" onClick={load} loading={loading}>
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{data && derived && (
|
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
|
||||||
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
广告展示与收益
|
|
||||||
</Typography.Text>
|
|
||||||
</Divider>
|
|
||||||
<Row gutter={[16, 12]}>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic title="展示条数合计" value={data.total_impressions} />
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic title="广告事件数" value={data.total} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
发奖与对账
|
|
||||||
</Typography.Text>
|
|
||||||
</Divider>
|
|
||||||
<Row gutter={[16, 12]}>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic title="应发金币合计" value={data.total_expected_coin} />
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic title="实发金币合计" value={data.total_actual_coin} />
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Statistic
|
|
||||||
title="预估毛利(元)"
|
|
||||||
value={derived.grossProfit}
|
|
||||||
precision={4}
|
|
||||||
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
|
||||||
valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }}
|
|
||||||
/>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
发奖占收益{' '}
|
|
||||||
{derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`}
|
|
||||||
</Typography.Text>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
|
||||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
|
|
||||||
{data.mismatch_count > 0 ? (
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
showIcon
|
|
||||||
message={`共 ${data.total} 条广告事件,其中 ${data.mismatch_count} 条应发≠实发(✗);应发−实发 合计 ${
|
|
||||||
derived && derived.coinGap >= 0 ? '+' : ''
|
|
||||||
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Alert type="success" showIcon message={`共 ${data.total} 条广告事件,应发与实发全部一致。`} />
|
|
||||||
)}
|
|
||||||
{data.truncated && (
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
message={`明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/类型/日期缩小范围,或调大「条数」。合计数字不受影响。`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data &&
|
|
||||||
(queriedMultiDay
|
|
||||||
? (data.daily?.length ?? 0) > 0
|
|
||||||
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
extra={
|
|
||||||
<Space size={16}>
|
|
||||||
<span style={{ fontSize: 12, color: '#666' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
background: CHART_BAR,
|
|
||||||
borderRadius: 2,
|
|
||||||
marginRight: 4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
展示条数
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 12, color: '#666' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 14,
|
|
||||||
borderTop: `2px solid ${CHART_LINE}`,
|
|
||||||
marginRight: 4,
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
预估收益(元)
|
|
||||||
</span>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
|
|
||||||
{!queriedMultiDay && data.truncated && (
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{queriedMultiDay ? (
|
|
||||||
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
|
|
||||||
) : (
|
|
||||||
<TrendChart points={aggregateHourly(data.items)} />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Table
|
|
||||||
rowKey="event_key"
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 1200 }}
|
|
||||||
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
|
||||||
expandable={{
|
|
||||||
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
|
|
||||||
rowExpandable: (r) => r.reward_detail != null,
|
|
||||||
expandedRowRender: (r) =>
|
|
||||||
r.reward_detail ? (
|
|
||||||
<div>
|
|
||||||
<Typography.Text strong>金币复算明细</Typography.Text>{' '}
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
(本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin})
|
|
||||||
</Typography.Text>
|
|
||||||
<Table<AdRevenueRecord>
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
rowKey="record_id"
|
|
||||||
columns={DETAIL_COLUMNS}
|
|
||||||
dataSource={[r.reward_detail]}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 900 }}
|
|
||||||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Modal
|
|
||||||
title="金币计算规则"
|
|
||||||
open={formulaOpen}
|
|
||||||
onCancel={() => setFormulaOpen(false)}
|
|
||||||
footer={null}
|
|
||||||
width={680}
|
|
||||||
>
|
|
||||||
<Typography.Paragraph>
|
|
||||||
<b>单次奖励(元)</b> =(单次 eCPM<sub>元</sub> ÷ 1000)× <b>因子1</b>(eCPM 档)× <b>因子2</b>(LT 累计条数)
|
|
||||||
</Typography.Paragraph>
|
|
||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
|
|
||||||
· eCPM 取自穿山甲 SDK <Typography.Text code>getEcpm()</Typography.Text> 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。
|
|
||||||
<br />· 金币汇率:<b>1 元 = {COIN_PER_YUAN.toLocaleString()} 金币</b>,最终四舍五入取整。
|
|
||||||
<br />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。
|
|
||||||
<br />· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。
|
|
||||||
</Typography.Paragraph>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Typography.Text strong>因子1 ·按 eCPM(元/千次)判档</Typography.Text>
|
|
||||||
<Table
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
size="small"
|
|
||||||
pagination={false}
|
|
||||||
dataSource={ECPM_FACTOR_ROWS}
|
|
||||||
columns={[
|
|
||||||
{ title: 'eCPM 范围(元)', dataIndex: 'range' },
|
|
||||||
{ title: '因子1', dataIndex: 'factor', width: 80 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Typography.Text strong>因子2 ·按账号 LT 累计条数</Typography.Text>
|
|
||||||
<Table
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
size="small"
|
|
||||||
pagination={false}
|
|
||||||
dataSource={LT_FACTOR_ROWS}
|
|
||||||
columns={[
|
|
||||||
{ title: 'LT 累计条数', dataIndex: 'lt' },
|
|
||||||
{ title: '因子2', dataIndex: 'factor', width: 80 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginTop: 12, marginBottom: 0 }}>
|
|
||||||
示例:eCPM = 300 元/千次、账号第 1 条 → 0.3 × 0.4 × 2.0 = 0.24 元 = 2,400 金币。
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<style jsx global>{`
|
|
||||||
.row-mismatch > td {
|
|
||||||
background: #fff1f0 !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { SorterResult } from 'antd/es/table/interface';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Card, Col, Input, Row, Select, Space, Statistic, Table, Tag, Tooltip } from 'antd';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { formatUtcTime, utcFromNow } from '@/lib/format';
|
||||||
|
import { usePagedList } from '@/lib/usePagedList';
|
||||||
|
import type { DeviceLivenessItem, DeviceLivenessStats } from '@/lib/types';
|
||||||
|
|
||||||
|
type SortField = 'status' | 'last_heartbeat_at' | 'created_at';
|
||||||
|
|
||||||
|
// 存活状态(后端按心跳阈值派生 display_state)→ 标签。operator 口径,不暴露内部 alive/notified/silent。
|
||||||
|
const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||||||
|
online: { color: 'green', label: '在线' },
|
||||||
|
offline: { color: 'red', label: '已掉线' },
|
||||||
|
never: { color: 'default', label: '未启用' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 秒 → 人话时长(掉线时长用)。 */
|
||||||
|
function fmtDuration(sec: number | null): string {
|
||||||
|
if (sec == null) return '';
|
||||||
|
if (sec < 60) return `${sec} 秒`;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
if (m < 60) return `${m} 分钟`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h} 小时 ${m % 60} 分`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
return `${d} 天 ${h % 24} 小时`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂无数据的字段统一占位(待客户端上报 / 待加字段)。
|
||||||
|
const pending = (hint: string) => (
|
||||||
|
<Tooltip title={hint}>
|
||||||
|
<Tag color="default">待接入</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DeviceLivenessPage() {
|
||||||
|
// 筛选草稿:点「查询」才应用
|
||||||
|
const [deviceId, setDeviceId] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [status, setStatus] = useState<string | undefined>();
|
||||||
|
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||||
|
// 排序:默认 status = 掉线置顶;点「最近心跳」列头切换
|
||||||
|
const [sortBy, setSortBy] = useState<SortField>('status');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
const filters = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||||
|
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<DeviceLivenessItem>(
|
||||||
|
'/admin/api/device-liveness',
|
||||||
|
filters,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 顶部卡片统计(全局,不随筛选)
|
||||||
|
const [stats, setStats] = useState<DeviceLivenessStats | null>(null);
|
||||||
|
const loadStats = useCallback(() => {
|
||||||
|
api.get<DeviceLivenessStats>('/admin/api/device-liveness/stats').then((r) => setStats(r.data));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
}, [loadStats]);
|
||||||
|
|
||||||
|
const search = () =>
|
||||||
|
setApplied({
|
||||||
|
status,
|
||||||
|
device_id: deviceId || undefined,
|
||||||
|
phone: phone || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setDeviceId('');
|
||||||
|
setPhone('');
|
||||||
|
setStatus(undefined);
|
||||||
|
setSortBy('status');
|
||||||
|
setSortOrder('desc');
|
||||||
|
setApplied({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAll = () => {
|
||||||
|
reload();
|
||||||
|
loadStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTableChange = (
|
||||||
|
_pagination: unknown,
|
||||||
|
_filters: unknown,
|
||||||
|
sorter: SorterResult<DeviceLivenessItem> | SorterResult<DeviceLivenessItem>[],
|
||||||
|
) => {
|
||||||
|
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||||
|
if (s && s.order && s.field) {
|
||||||
|
setSortBy(s.field as SortField);
|
||||||
|
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
|
||||||
|
} else {
|
||||||
|
// 取消列排序 → 回默认「掉线置顶」
|
||||||
|
setSortBy('status');
|
||||||
|
setSortOrder('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortOrderOf = (field: SortField) =>
|
||||||
|
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||||||
|
|
||||||
|
const cards = stats
|
||||||
|
? [
|
||||||
|
{ title: '总设备数', value: stats.total, color: undefined as string | undefined },
|
||||||
|
{ title: '在线', value: stats.online, color: '#3f8600' },
|
||||||
|
{ title: '已掉线', value: stats.offline, color: stats.offline > 0 ? '#cf1322' : undefined },
|
||||||
|
{ title: '未启用', value: stats.never, color: undefined },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 列顺序对齐产品给的字段清单
|
||||||
|
const columns: ColumnsType<DeviceLivenessItem> = [
|
||||||
|
{ title: '设备 ID', dataIndex: 'device_id', fixed: 'left', width: 210, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '归属用户',
|
||||||
|
key: 'user',
|
||||||
|
width: 150,
|
||||||
|
render: (_: unknown, d: DeviceLivenessItem) => (
|
||||||
|
<div>
|
||||||
|
<div>{d.phone || `user#${d.user_id}`}</div>
|
||||||
|
{d.nickname && <div style={{ color: '#888', fontSize: 12 }}>{d.nickname}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '机型', dataIndex: 'device_model', width: 130, render: (v: string | null) => v || '-' },
|
||||||
|
{
|
||||||
|
title: '系统&系统版本',
|
||||||
|
key: 'os',
|
||||||
|
width: 130,
|
||||||
|
render: () => pending('设备未上报系统版本,需客户端在注册/心跳里带上 + 后端加列'),
|
||||||
|
},
|
||||||
|
{ title: 'APP 版本', dataIndex: 'app_version', width: 100, render: (v: string | null) => v || '-' },
|
||||||
|
{
|
||||||
|
title: '最近一次心跳',
|
||||||
|
dataIndex: 'last_heartbeat_at',
|
||||||
|
width: 150,
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortOrderOf('last_heartbeat_at'),
|
||||||
|
render: (v: string | null) =>
|
||||||
|
v ? <Tooltip title={formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss')}>{utcFromNow(v)}</Tooltip> : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '存活状态',
|
||||||
|
dataIndex: 'display_state',
|
||||||
|
width: 130,
|
||||||
|
render: (s: string, d: DeviceLivenessItem) => {
|
||||||
|
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tag color={t.color}>{t.label}</Tag>
|
||||||
|
{s === 'offline' && d.offline_seconds != null && (
|
||||||
|
<div style={{ color: '#cf1322', fontSize: 12 }}>已 {fmtDuration(d.offline_seconds)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '首次无障碍开启时间',
|
||||||
|
dataIndex: 'first_protected_at',
|
||||||
|
width: 150,
|
||||||
|
render: (v: string | null) =>
|
||||||
|
v ? (
|
||||||
|
formatUtcTime(v)
|
||||||
|
) : (
|
||||||
|
<Tooltip title="迁移前已开过无障碍的老设备无此记录;之后首次开无障碍的设备会记录">
|
||||||
|
<span style={{ color: '#bbb' }}>-</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>设备存活监控</h2>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Col key={c.title} xs={12} sm={6} md={6} xl={4}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={c.title}
|
||||||
|
value={c.value}
|
||||||
|
valueStyle={c.color ? { color: c.color } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Select
|
||||||
|
placeholder="存活状态"
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'offline', label: '已掉线' },
|
||||||
|
{ value: 'online', label: '在线' },
|
||||||
|
{ value: 'never', label: '未启用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="设备ID(包含)"
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => setDeviceId(e.target.value)}
|
||||||
|
onPressEnter={search}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="归属用户手机号(前缀)"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
onPressEnter={search}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={search}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 台设备`,
|
||||||
|
onChange,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
onChange={onTableChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
|
HeartOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
MoneyCollectOutlined,
|
MoneyCollectOutlined,
|
||||||
|
NotificationOutlined,
|
||||||
ProfileOutlined,
|
ProfileOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
@@ -27,11 +29,13 @@ const MENU = [
|
|||||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||||
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
|
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
|
||||||
|
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
||||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||||
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告管理' },
|
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
|
||||||
|
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
|
||||||
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
|
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
|
||||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||||
|
|||||||
@@ -44,6 +44,39 @@ export interface DeviceOnboardingItem {
|
|||||||
last_completed_at: string;
|
last_completed_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设备存活监控:device_liveness 表一行 + 后端按心跳阈值派生的在线/掉线/掉线时长。
|
||||||
|
export interface DeviceLivenessItem {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
phone: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
device_id: string;
|
||||||
|
device_model: string | null; // 由 device_id 解析的机型
|
||||||
|
platform: string;
|
||||||
|
app_version: string | null;
|
||||||
|
registration_id: string | null; // 非空 = 可极光推送
|
||||||
|
ever_protected: boolean;
|
||||||
|
first_protected_at: string | null; // 首次开无障碍时刻(老设备为 null)
|
||||||
|
last_heartbeat_at: string | null;
|
||||||
|
last_report_protection_on: boolean;
|
||||||
|
liveness_state: string; // unknown / alive / silent(当前未用) / notified
|
||||||
|
notified_at: string | null;
|
||||||
|
kill_alert_pending: boolean; // 掉线召回待客户端 ack
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// 后端派生
|
||||||
|
online: boolean;
|
||||||
|
display_state: string; // online 在线 / offline 已掉线 / never 未启用
|
||||||
|
offline_seconds: number | null; // 掉线时长(秒);仅 offline 有值
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLivenessStats {
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
offline: number;
|
||||||
|
never: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserOverview {
|
export interface UserOverview {
|
||||||
user: UserListItem;
|
user: UserListItem;
|
||||||
coin_balance: number;
|
coin_balance: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user