Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b9fdd2fc0 | |||
| c5534504c7 | |||
| 94d1e8f2b1 | |||
| 8f47278f04 |
@@ -9,7 +9,6 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Descriptions,
|
|
||||||
Divider,
|
Divider,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -34,11 +32,9 @@ import { api, errMsg } from '@/lib/api';
|
|||||||
import { formatUtcTime } from '@/lib/format';
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import type {
|
import type {
|
||||||
AdRevenueDaily,
|
AdRevenueDaily,
|
||||||
AdRevenueHourly,
|
|
||||||
AdRevenueRecord,
|
AdRevenueRecord,
|
||||||
AdRevenueReport,
|
AdRevenueReport,
|
||||||
AdRevenueRow,
|
AdRevenueRow,
|
||||||
AdRevenueTypeStat,
|
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@@ -51,13 +47,6 @@ const TYPE_TAG: Record<string, { color: string; label: string }> = {
|
|||||||
withdrawal_video: { color: 'gold', label: '提现激励视频' },
|
withdrawal_video: { color: 'gold', label: '提现激励视频' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draw 信息流投放场景标签(后端 AdRevenueRow.feed_scene:comparison/coupon/welfare)
|
|
||||||
const SCENE_TAG: Record<string, { text: string; color: string }> = {
|
|
||||||
comparison: { text: '比价', color: 'blue' },
|
|
||||||
coupon: { text: '领券', color: 'gold' },
|
|
||||||
welfare: { text: '福利', color: 'green' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 我们的应用环境标签
|
// 我们的应用环境标签
|
||||||
const APP_TAG: Record<string, { color: string; label: string }> = {
|
const APP_TAG: Record<string, { color: string; label: string }> = {
|
||||||
prod: { color: 'green', label: '傻瓜比价(正式)' },
|
prod: { color: 'green', label: '傻瓜比价(正式)' },
|
||||||
@@ -150,8 +139,8 @@ interface TrendPoint {
|
|||||||
revenue: number;
|
revenue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按小时聚合(0–23),数据源是 hourly(后端全量聚合,不受分页影响)
|
// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告)
|
||||||
function aggregateHourly(rows: AdRevenueHourly[]): TrendPoint[] {
|
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
|
||||||
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
|
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
|
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
|
||||||
@@ -267,12 +256,8 @@ export default function AdRevenueReportPage() {
|
|||||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
||||||
const [userId, setUserId] = useState<number | null>(null);
|
const [userId, setUserId] = useState<number | null>(null);
|
||||||
const [adType, setAdType] = useState<string | undefined>();
|
const [adType, setAdType] = useState<string | undefined>();
|
||||||
// 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。
|
|
||||||
const [scene, setScene] = useState<string | undefined>();
|
|
||||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||||
const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序
|
const [limit, setLimit] = useState<number>(500);
|
||||||
const [limit, setLimit] = useState<number>(500); // 每页条数(分页大小)
|
|
||||||
const [page, setPage] = useState<number>(1); // 当前页码(后端分页;1 起)
|
|
||||||
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
|
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
|
||||||
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
|
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
|
||||||
const [data, setData] = useState<AdRevenueReport | null>(null);
|
const [data, setData] = useState<AdRevenueReport | null>(null);
|
||||||
@@ -283,41 +268,33 @@ export default function AdRevenueReportPage() {
|
|||||||
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
|
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
|
||||||
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
|
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
|
||||||
|
|
||||||
// load(目标页, 每页条数):分页与筛选都经它。offset 由 页码×每页 算;场景(feed_scene)随筛选下推后端。
|
const load = useCallback(async () => {
|
||||||
const load = useCallback(
|
const from = range[0].format('YYYY-MM-DD');
|
||||||
async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => {
|
const to = range[1].format('YYYY-MM-DD');
|
||||||
const from = range[0].format('YYYY-MM-DD');
|
const multiDay = from !== to;
|
||||||
const to = range[1].format('YYYY-MM-DD');
|
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
|
||||||
const multiDay = from !== to;
|
setLoading(true);
|
||||||
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
|
try {
|
||||||
setLoading(true);
|
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||||
try {
|
params: {
|
||||||
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
date_from: from,
|
||||||
params: {
|
date_to: to,
|
||||||
date_from: from,
|
user_id: userId ?? undefined,
|
||||||
date_to: to,
|
ad_type: adType ?? undefined,
|
||||||
user_id: userId ?? undefined,
|
granularity: gran,
|
||||||
ad_type: adType ?? undefined,
|
limit,
|
||||||
feed_scene: scene ?? undefined,
|
},
|
||||||
granularity: gran,
|
});
|
||||||
limit: targetLimit,
|
setData(res.data);
|
||||||
offset: (targetPage - 1) * targetLimit,
|
setQueriedLimit(limit);
|
||||||
sort: targetSort,
|
setQueriedGranularity(gran);
|
||||||
},
|
setQueriedMultiDay(multiDay);
|
||||||
});
|
} catch (e) {
|
||||||
setData(res.data);
|
message.error(errMsg(e));
|
||||||
setPage(targetPage);
|
} finally {
|
||||||
setQueriedLimit(targetLimit);
|
setLoading(false);
|
||||||
setQueriedGranularity(gran);
|
}
|
||||||
setQueriedMultiDay(multiDay);
|
}, [range, userId, adType, granularity, limit]);
|
||||||
} catch (e) {
|
|
||||||
message.error(errMsg(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[range, userId, adType, scene, granularity, limit, sortBy],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -355,16 +332,6 @@ export default function AdRevenueReportPage() {
|
|||||||
return <Tag color={t.color}>{t.label}</Tag>;
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '场景',
|
|
||||||
dataIndex: 'feed_scene',
|
|
||||||
width: 80,
|
|
||||||
render: (v: string | null | undefined) => {
|
|
||||||
if (!v) return <Typography.Text type="secondary">-</Typography.Text>;
|
|
||||||
const t = SCENE_TAG[v];
|
|
||||||
return t ? <Tag color={t.color}>{t.text}</Tag> : <Tag>{v}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '来源应用',
|
title: '来源应用',
|
||||||
dataIndex: 'app_env',
|
dataIndex: 'app_env',
|
||||||
@@ -375,6 +342,13 @@ export default function AdRevenueReportPage() {
|
|||||||
return <Tag color={t.color}>{t.label}</Tag>;
|
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(分)',
|
title: 'eCPM(分)',
|
||||||
dataIndex: 'ecpm',
|
dataIndex: 'ecpm',
|
||||||
@@ -382,17 +356,6 @@ export default function AdRevenueReportPage() {
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: string | null) => v ?? '-',
|
render: (v: string | null) => v ?? '-',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'eCPM(元)',
|
|
||||||
key: 'ecpm_yuan',
|
|
||||||
width: 100,
|
|
||||||
align: 'right',
|
|
||||||
render: (_: unknown, r: AdRevenueRow) => {
|
|
||||||
// eCPM 原始值单位「分/千次」,÷100 转「元/千次」(块);非数字 / 空显示 -。
|
|
||||||
const cents = r.ecpm == null ? null : Number(r.ecpm);
|
|
||||||
return cents == null || Number.isNaN(cents) ? '-' : (cents / 100).toFixed(2);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '预估收益(元)',
|
title: '预估收益(元)',
|
||||||
dataIndex: 'revenue_yuan',
|
dataIndex: 'revenue_yuan',
|
||||||
@@ -438,13 +401,6 @@ export default function AdRevenueReportPage() {
|
|||||||
<Typography.Text type="secondary">-</Typography.Text>
|
<Typography.Text type="secondary">-</Typography.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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: '底层 ADN',
|
title: '底层 ADN',
|
||||||
dataIndex: 'adn',
|
dataIndex: 'adn',
|
||||||
@@ -454,11 +410,14 @@ export default function AdRevenueReportPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
|
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
|
||||||
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
|
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
|
||||||
// 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 预估收益÷今日DAU(仅今日)。
|
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
|
||||||
const derived = data
|
const derived = data
|
||||||
? {
|
? {
|
||||||
|
avgEcpm: data.total_impressions
|
||||||
|
? (data.total_revenue_yuan / data.total_impressions) * 1000
|
||||||
|
: 0,
|
||||||
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
|
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
|
||||||
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
|
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
|
||||||
payoutRatioPct:
|
payoutRatioPct:
|
||||||
@@ -466,20 +425,9 @@ export default function AdRevenueReportPage() {
|
|||||||
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
|
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
|
||||||
: null,
|
: null,
|
||||||
coinGap: data.total_expected_coin - data.total_actual_coin,
|
coinGap: data.total_expected_coin - data.total_actual_coin,
|
||||||
// ARPU(今日)= 预估广告收益 ÷ 今日 DAU;dau 为 null(历史/多天)或 0 时不可算 → null,前端显示「-」。
|
|
||||||
arpu: data.dau && data.dau > 0 ? data.total_revenue_yuan / data.dau : null,
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 第二行大盘「分广告类型」:draw / 激励视频 各自 收益 / eCPM / 展示条数;eCPM = 收益÷展示×1000。
|
|
||||||
const drawStat = data?.type_stats?.draw;
|
|
||||||
const rvStat = data?.type_stats?.reward_video;
|
|
||||||
const ecpmOf = (s?: AdRevenueTypeStat) =>
|
|
||||||
s && s.impressions > 0 ? (s.revenue_yuan / s.impressions) * 1000 : 0;
|
|
||||||
|
|
||||||
// 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。
|
|
||||||
const items = data?.items ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
||||||
@@ -549,21 +497,6 @@ export default function AdRevenueReportPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<Space size={6}>
|
|
||||||
<Typography.Text type="secondary">场景</Typography.Text>
|
|
||||||
<Select
|
|
||||||
placeholder="全部"
|
|
||||||
value={scene}
|
|
||||||
onChange={setScene}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 130 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'comparison', label: '比价' },
|
|
||||||
{ value: 'coupon', label: '领券' },
|
|
||||||
{ value: 'welfare', label: '福利' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Space size={6}>
|
<Space size={6}>
|
||||||
<Typography.Text type="secondary">粒度</Typography.Text>
|
<Typography.Text type="secondary">粒度</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
@@ -579,33 +512,15 @@ export default function AdRevenueReportPage() {
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<Space size={6}>
|
<Space size={6}>
|
||||||
<Typography.Text type="secondary">排序</Typography.Text>
|
<Typography.Text type="secondary">条数</Typography.Text>
|
||||||
<Select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSortBy(v);
|
|
||||||
load(1, limit, v); // 改排序:回第 1 页并重查
|
|
||||||
}}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'time', label: '时间倒序' },
|
|
||||||
{ value: 'ecpm', label: 'eCPM 倒序' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Space size={6}>
|
|
||||||
<Typography.Text type="secondary">每页</Typography.Text>
|
|
||||||
<Select
|
<Select
|
||||||
value={limit}
|
value={limit}
|
||||||
onChange={(v) => {
|
onChange={setLimit}
|
||||||
setLimit(v);
|
style={{ width: 110 }}
|
||||||
load(1, v); // 改每页大小:回到第 1 页并重查
|
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||||
}}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
options={[20, 50, 100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条/页` }))}
|
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<Button type="primary" onClick={() => load(1)} loading={loading}>
|
<Button type="primary" onClick={load} loading={loading}>
|
||||||
查询
|
查询
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -615,46 +530,41 @@ export default function AdRevenueReportPage() {
|
|||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
|
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
核心指标
|
广告展示与收益
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Divider>
|
</Divider>
|
||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
<Col flex="1 1 0">
|
<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} />
|
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col flex="1 1 0">
|
<Col span={6}>
|
||||||
<Statistic
|
<Statistic title="广告事件数" value={data.total} />
|
||||||
title={
|
|
||||||
<Tooltip title="每活跃用户广告预估收入 = 预估收益 ÷ 今日 DAU;DAU 仅今日口径,历史/多天显示 -">
|
|
||||||
<span>
|
|
||||||
ARPU(今日)
|
|
||||||
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
value={derived.arpu ?? '-'}
|
|
||||||
precision={4}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col flex="1 1 0">
|
</Row>
|
||||||
<Statistic
|
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
||||||
title={
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<Tooltip title="今日活跃用户(复用大盘口径 last_login_at = 今日登录过);仅查询=今日时有值,历史/多天显示 -">
|
发奖与对账
|
||||||
<span>
|
</Typography.Text>
|
||||||
今日活跃 DAU
|
</Divider>
|
||||||
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
<Row gutter={[16, 12]}>
|
||||||
</span>
|
<Col span={6}>
|
||||||
</Tooltip>
|
<Statistic title="应发金币合计" value={data.total_expected_coin} />
|
||||||
}
|
|
||||||
value={data.dau ?? '-'}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col flex="1 1 0">
|
<Col span={6}>
|
||||||
<Statistic title="发放金币合计(元)" value={derived.payoutYuan} precision={4} />
|
<Statistic title="实发金币合计" value={data.total_actual_coin} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col flex="1 1 0">
|
<Col span={6}>
|
||||||
|
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="预估广告毛利(元)"
|
title="预估毛利(元)"
|
||||||
value={derived.grossProfit}
|
value={derived.grossProfit}
|
||||||
precision={4}
|
precision={4}
|
||||||
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
||||||
@@ -666,31 +576,6 @@ export default function AdRevenueReportPage() {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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={4}>
|
|
||||||
<Statistic title="Draw 信息流收益(元)" value={drawStat?.revenue_yuan ?? 0} precision={4} />
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Statistic title="Draw 信息流 eCPM(元/千次)" value={ecpmOf(drawStat)} precision={2} />
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Statistic title="Draw 信息流条数" value={drawStat?.impressions ?? 0} />
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Statistic title="激励视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} />
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Statistic title="激励视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} />
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Statistic title="激励视频条数" value={rvStat?.impressions ?? 0} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -707,13 +592,20 @@ export default function AdRevenueReportPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Alert type="success" showIcon message={`共 ${data.total} 条广告事件,应发与实发全部一致。`} />
|
<Alert type="success" showIcon message={`共 ${data.total} 条广告事件,应发与实发全部一致。`} />
|
||||||
)}
|
)}
|
||||||
|
{data.truncated && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={`明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/类型/日期缩小范围,或调大「条数」。合计数字不受影响。`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data &&
|
{data &&
|
||||||
(queriedMultiDay
|
(queriedMultiDay
|
||||||
? (data.daily?.length ?? 0) > 0
|
? (data.daily?.length ?? 0) > 0
|
||||||
: queriedGranularity === 'hour' && (data.hourly?.length ?? 0) > 0) && (
|
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
|
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
|
||||||
@@ -748,11 +640,19 @@ export default function AdRevenueReportPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* 趋势图恒为全量(不受分页影响):按天图用 daily,按小时图用 hourly;「场景」已下推后端,趋势随场景筛选一并变化。 */}
|
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
|
||||||
|
{!queriedMultiDay && data.truncated && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{queriedMultiDay ? (
|
{queriedMultiDay ? (
|
||||||
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
|
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
|
||||||
) : (
|
) : (
|
||||||
<TrendChart points={aggregateHourly(data.hourly)} />
|
<TrendChart points={aggregateHourly(data.items)} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -760,22 +660,15 @@ export default function AdRevenueReportPage() {
|
|||||||
<Table
|
<Table
|
||||||
rowKey="event_key"
|
rowKey="event_key"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={items}
|
dataSource={data?.items ?? []}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={false}
|
||||||
current: page,
|
|
||||||
pageSize: queriedLimit,
|
|
||||||
total: data?.total ?? 0,
|
|
||||||
showSizeChanger: false,
|
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
onChange: (p) => load(p, queriedLimit),
|
|
||||||
}}
|
|
||||||
size="small"
|
size="small"
|
||||||
scroll={{ x: 1380 }}
|
scroll={{ x: 1200 }}
|
||||||
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
||||||
expandable={{
|
expandable={{
|
||||||
// 每行都可展开(左侧恒有 + 号):有发奖看「金币复算因子」;纯展示看「展示明细」。
|
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
|
||||||
rowExpandable: () => true,
|
rowExpandable: (r) => r.reward_detail != null,
|
||||||
expandedRowRender: (r) =>
|
expandedRowRender: (r) =>
|
||||||
r.reward_detail ? (
|
r.reward_detail ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -794,28 +687,7 @@ export default function AdRevenueReportPage() {
|
|||||||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null,
|
||||||
<div>
|
|
||||||
<Typography.Text strong>展示明细</Typography.Text>{' '}
|
|
||||||
<Typography.Text type="secondary">(纯展示事件,未单独发奖,无金币复算)</Typography.Text>
|
|
||||||
<Descriptions size="small" column={3} bordered style={{ marginTop: 8 }}>
|
|
||||||
<Descriptions.Item label="时间">{formatUtcTime(r.created_at)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="eCPM(分)">{r.ecpm ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="eCPM(元)">
|
|
||||||
{r.ecpm != null && !Number.isNaN(Number(r.ecpm))
|
|
||||||
? (Number(r.ecpm) / 100).toFixed(2)
|
|
||||||
: '-'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="预估收益(元)">
|
|
||||||
{r.has_impression ? r.revenue_yuan.toFixed(4) : '-'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="底层 ADN">{r.adn ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="底层代码位(rit)">{r.slot_id ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="我方代码位">{r.our_code_id ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="来源应用">{r.app_env ?? '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -831,7 +703,7 @@ export default function AdRevenueReportPage() {
|
|||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
|
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
|
||||||
· eCPM 取自穿山甲 SDK <Typography.Text code>getEcpm()</Typography.Text> 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。
|
· eCPM 取自穿山甲 SDK <Typography.Text code>getEcpm()</Typography.Text> 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。
|
||||||
<br />· 金币汇率:<b>1 元 = {COIN_PER_YUAN.toLocaleString()} 金币</b>,最终四舍五入取整。
|
<br />· 金币汇率:<b>1 元 = {COIN_PER_YUAN.toLocaleString()} 金币</b>,最终四舍五入取整。
|
||||||
<br />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);<b>一条广告 = 1 条</b>(激励视频每次 1 条;信息流看满 10 秒即发该条满额,不按时长折多份)。
|
<br />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。
|
||||||
<br />· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。
|
<br />· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { api, errMsg } from '@/lib/api';
|
|||||||
interface AdCfg {
|
interface AdCfg {
|
||||||
app_id: string;
|
app_id: string;
|
||||||
reward_code_id: string;
|
reward_code_id: string;
|
||||||
compare_draw_code_id: string;
|
compare_feed_code_id: string;
|
||||||
coupon_draw_code_id: string;
|
coupon_feed_code_id: string;
|
||||||
reward_mkey: string;
|
reward_mkey: string;
|
||||||
reward_enabled: boolean;
|
reward_enabled: boolean;
|
||||||
compare_ad_enabled: boolean;
|
compare_ad_enabled: boolean;
|
||||||
@@ -74,11 +74,11 @@ export default function AdConfigPage() {
|
|||||||
>
|
>
|
||||||
<Input.Password placeholder="GroMore S2S 回调验签密钥" autoComplete="off" />
|
<Input.Password placeholder="GroMore S2S 回调验签密钥" autoComplete="off" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="compare_draw_code_id" label="比价Draw信息流代码位" rules={[{ required: true }]}>
|
<Form.Item name="compare_feed_code_id" label="比价 · 信息流 广告位ID" rules={[{ required: true }]}>
|
||||||
<Input placeholder="比价Draw信息流代码位,如 104098712" />
|
<Input placeholder="如 104090333" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="coupon_draw_code_id" label="领券Draw信息流代码位" rules={[{ required: true }]}>
|
<Form.Item name="coupon_feed_code_id" label="领券 · 信息流 广告位ID" rules={[{ required: true }]}>
|
||||||
<Input placeholder="领券Draw信息流代码位,如 104098712" />
|
<Input placeholder="如 104090333" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="reward_enabled" label="福利激励视频开关" valuePropName="checked">
|
<Form.Item name="reward_enabled" label="福利激励视频开关" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import type { SorterResult } from 'antd/es/table/interface';
|
|
||||||
import { Button, DatePicker, Input, Space, Switch, Table, Tag, Typography } from 'antd';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { formatUtcTime } from '@/lib/format';
|
|
||||||
import { usePagedList } from '@/lib/usePagedList';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
|
|
||||||
/** 一条埋点日志(对应后端 analytics_event,五维)。 */
|
|
||||||
type EventLog = {
|
|
||||||
id: number;
|
|
||||||
event: string;
|
|
||||||
device_id: string;
|
|
||||||
user_id: number | null;
|
|
||||||
session_id: string | null;
|
|
||||||
client_ts: number;
|
|
||||||
sent_at: number | null;
|
|
||||||
created_at: string;
|
|
||||||
page: string | null;
|
|
||||||
client_ip: string | null;
|
|
||||||
oem: string | null;
|
|
||||||
os: string | null;
|
|
||||||
model: string | null;
|
|
||||||
app_ver: string | null;
|
|
||||||
network: string | null;
|
|
||||||
channel: string | null;
|
|
||||||
props: Record<string, string> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SortField = 'id' | 'created_at';
|
|
||||||
|
|
||||||
// 端时间是 epoch ms;格式化成「月-日 时:分:秒.毫秒」便于核对节流/顺序
|
|
||||||
const fmtMs = (ms: number | null) => (ms ? dayjs(ms).format('MM-DD HH:mm:ss.SSS') : '-');
|
|
||||||
|
|
||||||
export default function EventLogsPage() {
|
|
||||||
// 筛选草稿:点「查询」才应用
|
|
||||||
const [event, setEvent] = useState('');
|
|
||||||
const [deviceId, setDeviceId] = useState('');
|
|
||||||
const [userId, setUserId] = useState('');
|
|
||||||
const [sessionId, setSessionId] = useState('');
|
|
||||||
const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null);
|
|
||||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
|
||||||
// 排序(服务端)
|
|
||||||
const [sortBy, setSortBy] = useState<SortField>('id');
|
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
||||||
// 近实时:自动刷新开关
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
||||||
|
|
||||||
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
|
||||||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
|
||||||
usePagedList<EventLog>('/admin/api/event-logs', filters);
|
|
||||||
|
|
||||||
// 开自动刷新后每 5s 拉当前页;reload 闭包随渲染更新,用 ref 取最新,避免 interval captured 过期闭包。
|
|
||||||
const reloadRef = useRef(reload);
|
|
||||||
reloadRef.current = reload;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoRefresh) return;
|
|
||||||
const t = setInterval(() => reloadRef.current(), 5000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, [autoRefresh]);
|
|
||||||
|
|
||||||
const search = () =>
|
|
||||||
setApplied({
|
|
||||||
event: event.trim() || undefined,
|
|
||||||
device_id: deviceId.trim() || undefined,
|
|
||||||
user_id: userId.trim() ? Number(userId.trim()) : undefined,
|
|
||||||
session_id: sessionId.trim() || undefined,
|
|
||||||
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
|
|
||||||
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
setEvent('');
|
|
||||||
setDeviceId('');
|
|
||||||
setUserId('');
|
|
||||||
setSessionId('');
|
|
||||||
setCreatedRange(null);
|
|
||||||
setSortBy('id');
|
|
||||||
setSortOrder('desc');
|
|
||||||
setApplied({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTableChange = (
|
|
||||||
_p: unknown,
|
|
||||||
_f: unknown,
|
|
||||||
sorter: SorterResult<EventLog> | SorterResult<EventLog>[],
|
|
||||||
) => {
|
|
||||||
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('id');
|
|
||||||
setSortOrder('desc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortOrderOf = (field: SortField) =>
|
|
||||||
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
|
||||||
|
|
||||||
const columns: ColumnsType<EventLog> = [
|
|
||||||
{
|
|
||||||
title: '接收时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
width: 170,
|
|
||||||
sorter: true,
|
|
||||||
sortOrder: sortOrderOf('created_at'),
|
|
||||||
render: (v: string) => formatUtcTime(v),
|
|
||||||
},
|
|
||||||
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
|
|
||||||
{
|
|
||||||
title: '事件',
|
|
||||||
dataIndex: 'event',
|
|
||||||
width: 170,
|
|
||||||
render: (v: string) => <Tag color="blue">{v}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '页面',
|
|
||||||
dataIndex: 'page',
|
|
||||||
width: 100,
|
|
||||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '用户',
|
|
||||||
dataIndex: 'user_id',
|
|
||||||
width: 80,
|
|
||||||
render: (v: number | null) => v ?? <Text type="secondary">未登录</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '设备',
|
|
||||||
dataIndex: 'device_id',
|
|
||||||
width: 150,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string) => (
|
|
||||||
<Text style={{ fontSize: 12 }} copyable={{ text: v }}>
|
|
||||||
{v}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '网络', dataIndex: 'network', width: 70, render: (v: string | null) => v || '-' },
|
|
||||||
{
|
|
||||||
title: '机型',
|
|
||||||
key: 'oem',
|
|
||||||
width: 130,
|
|
||||||
render: (_: unknown, r: EventLog) => (
|
|
||||||
<Text style={{ fontSize: 12 }}>{[r.oem, r.os].filter(Boolean).join(' / ') || '-'}</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '版本', dataIndex: 'app_ver', width: 100, render: (v: string | null) => v || '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>埋点日志</h2>
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
|
||||||
<Input
|
|
||||||
placeholder="事件名 (如 video_play)"
|
|
||||||
value={event}
|
|
||||||
onChange={(e) => setEvent(e.target.value)}
|
|
||||||
onPressEnter={search}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 180 }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="设备ID(前缀)"
|
|
||||||
value={deviceId}
|
|
||||||
onChange={(e) => setDeviceId(e.target.value)}
|
|
||||||
onPressEnter={search}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 160 }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="用户ID"
|
|
||||||
value={userId}
|
|
||||||
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
|
|
||||||
onPressEnter={search}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 100 }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="会话ID"
|
|
||||||
value={sessionId}
|
|
||||||
onChange={(e) => setSessionId(e.target.value)}
|
|
||||||
onPressEnter={search}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 160 }}
|
|
||||||
/>
|
|
||||||
<RangePicker
|
|
||||||
placeholder={['接收起', '接收止']}
|
|
||||||
value={createdRange}
|
|
||||||
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
<Button type="primary" onClick={search}>
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
<Button onClick={resetFilters}>重置</Button>
|
|
||||||
<Button onClick={reload}>刷新</Button>
|
|
||||||
<Space size={4}>
|
|
||||||
<Switch checked={autoRefresh} onChange={setAutoRefresh} />
|
|
||||||
<Text type="secondary">自动刷新(5s)</Text>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
columns={columns}
|
|
||||||
dataSource={items}
|
|
||||||
loading={loading}
|
|
||||||
expandable={{
|
|
||||||
expandedRowRender: (r) => (
|
|
||||||
<Space direction="vertical" size={2} style={{ fontSize: 12 }}>
|
|
||||||
<Text type="secondary">
|
|
||||||
会话: {r.session_id || '-'} | 设备型号: {r.model || '-'} | 渠道: {r.channel || '-'} | IP:{' '}
|
|
||||||
{r.client_ip || '-'}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary">
|
|
||||||
端事件时间: {fmtMs(r.client_ts)} | 端上报时间: {fmtMs(r.sent_at)}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary">
|
|
||||||
属性: {r.props && Object.keys(r.props).length ? JSON.stringify(r.props) : '无'}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
pagination={{
|
|
||||||
current: page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
onChange: onPageChange,
|
|
||||||
}}
|
|
||||||
onChange={onTableChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,8 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
HeartOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
@@ -30,7 +28,6 @@ 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: '比价记录' },
|
||||||
@@ -40,7 +37,6 @@ const MENU = [
|
|||||||
{ 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 },
|
||||||
{ key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' },
|
|
||||||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+3
-55
@@ -44,39 +44,6 @@ 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;
|
||||||
@@ -365,7 +332,7 @@ export interface AdRevenueRecord {
|
|||||||
matched: boolean;
|
matched: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受分页影响)
|
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响)
|
||||||
export interface AdRevenueDaily {
|
export interface AdRevenueDaily {
|
||||||
date: string; // 北京时间 YYYY-MM-DD
|
date: string; // 北京时间 YYYY-MM-DD
|
||||||
impressions: number;
|
impressions: number;
|
||||||
@@ -374,21 +341,6 @@ export interface AdRevenueDaily {
|
|||||||
actual_coin: number;
|
actual_coin: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 广告收益报表:按北京小时(0–23)汇总的一小时(按小时趋势图用;全量,不受分页影响;按天查询时为空)
|
|
||||||
export interface AdRevenueHourly {
|
|
||||||
hour: number; // 北京时间小时 0–23
|
|
||||||
impressions: number;
|
|
||||||
revenue_yuan: number;
|
|
||||||
expected_coin: number;
|
|
||||||
actual_coin: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 广告收益报表:按广告类型(ad_type)的小计(展示条数 + 预估收益;eCPM 前端用 收益÷展示×1000 算)
|
|
||||||
export interface AdRevenueTypeStat {
|
|
||||||
impressions: number; // 该类型展示条数合计
|
|
||||||
revenue_yuan: number; // 该类型预估收益合计(元)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
|
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
|
||||||
export interface AdRevenueRow {
|
export interface AdRevenueRow {
|
||||||
event_key: string; // 事件稳定唯一键(前端 rowKey)
|
event_key: string; // 事件稳定唯一键(前端 rowKey)
|
||||||
@@ -396,7 +348,6 @@ export interface AdRevenueRow {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
user_phone: string | null; // 用户手机号(admin 展示;查不到为空)
|
user_phone: string | null; // 用户手机号(admin 展示;查不到为空)
|
||||||
ad_type: string; // reward_video / feed / draw
|
ad_type: string; // reward_video / feed / draw
|
||||||
feed_scene?: string | null; // Draw 信息流投放场景 comparison(比价) / coupon(领券) / welfare(福利);非信息流或旧数据为 null
|
|
||||||
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
|
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
|
||||||
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
|
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
|
||||||
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
|
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
|
||||||
@@ -421,11 +372,8 @@ export interface AdRevenueReport {
|
|||||||
date_from: string; // 起始日 北京时间 YYYY-MM-DD
|
date_from: string; // 起始日 北京时间 YYYY-MM-DD
|
||||||
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
|
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
|
||||||
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
||||||
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
|
total: number; // 广告事件总数(全量,不受 limit 影响)
|
||||||
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
|
truncated: boolean;
|
||||||
dau: number | null; // 今日活跃(复用大盘 last_login_at);仅查询=今日时有值,否则 null
|
|
||||||
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
|
|
||||||
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
|
|
||||||
total_impressions: number;
|
total_impressions: number;
|
||||||
total_revenue_yuan: number;
|
total_revenue_yuan: number;
|
||||||
total_expected_coin: number;
|
total_expected_coin: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user