Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de58e69933 | |||
| a84c4570ba | |||
| b1270481aa | |||
| 8ec2e99366 |
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
|
||||
|
||||
const SCENE_TAG: Record<string, { color: string; label: string }> = {
|
||||
reward_video: { color: 'blue', label: '看视频' },
|
||||
feed: { color: 'purple', label: '信息流' },
|
||||
};
|
||||
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
granted: 'green',
|
||||
capped: 'orange',
|
||||
ecpm_missing: 'red',
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
export default function AdAuditPage() {
|
||||
const [date, setDate] = useState<Dayjs>(dayjs());
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [scene, setScene] = useState<string | undefined>();
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
const [data, setData] = useState<AdCoinAudit | null>(null);
|
||||
const [queriedLimit, setQueriedLimit] = useState<number>(100); // 本次结果对应的 limit,截断提示里展示
|
||||
const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<AdCoinAudit>('/admin/api/ad-coin-audit', {
|
||||
params: {
|
||||
date: date.format('YYYY-MM-DD'),
|
||||
user_id: userId ?? undefined,
|
||||
scene: scene ?? undefined,
|
||||
limit,
|
||||
only_mismatch: onlyMismatch || undefined,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
setQueriedLimit(limit);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, userId, scene, limit, onlyMismatch]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// 仅首次自动拉今天;之后由「查询」按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<AdCoinAuditRow> = [
|
||||
{
|
||||
title: '场景',
|
||||
dataIndex: 'scene',
|
||||
width: 90,
|
||||
render: (s: string) => {
|
||||
const t = SCENE_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 175 },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 70 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (s: string) => <Tag color={STATUS_TAG[s] ?? 'default'}>{s}</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: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||
},
|
||||
{
|
||||
title: '因子2',
|
||||
key: 'lt_factor',
|
||||
width: 90,
|
||||
render: (_: unknown, r: AdCoinAuditRow) => 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: 70,
|
||||
render: (m: boolean) =>
|
||||
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>看广告金币审计</h2>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。
|
||||
</Typography.Paragraph>
|
||||
|
||||
{data && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong>金币公式:</Typography.Text>{' '}
|
||||
<Typography.Text code>{data.formula.description}</Typography.Text>
|
||||
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
|
||||
<div>
|
||||
eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '}
|
||||
{data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
因子1(按 eCPM 元判档,阈值单位:元):
|
||||
{data.formula.ecpm_factor_tiers
|
||||
.map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`)
|
||||
.join(' | ')}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
因子2(LT 累计条数):
|
||||
{data.formula.lt_factor_tiers
|
||||
.map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`)
|
||||
.join(' | ')}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<DatePicker value={date} onChange={(d) => d && setDate(d)} allowClear={false} />
|
||||
<InputNumber
|
||||
placeholder="用户 ID(可空)"
|
||||
value={userId ?? undefined}
|
||||
onChange={(v) => setUserId(v ?? null)}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="场景(全部)"
|
||||
value={scene}
|
||||
onChange={setScene}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'reward_video', label: '看视频' },
|
||||
{ value: 'feed', label: '信息流' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
style={{ width: 120 }}
|
||||
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||
/>
|
||||
<Checkbox checked={onlyMismatch} onChange={(e) => setOnlyMismatch(e.target.checked)}>
|
||||
只看不一致(✗)
|
||||
</Checkbox>
|
||||
<Button type="primary" onClick={load} loading={loading}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{data && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{data.mismatch_count > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="success" message={`共 ${data.total} 条,全部按公式发放(无不一致)`} />
|
||||
)}
|
||||
{onlyMismatch && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前只展示不一致(✗)行;上方统计仍为全量(共 ${data.total} 条)。`}
|
||||
/>
|
||||
)}
|
||||
{data.truncated && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`展示明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/场景/日期缩小范围,或调大「条数」。统计数字不受影响。`}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey={(r) => `${r.scene}-${r.record_id}`}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
.row-mismatch > td {
|
||||
background: #fff1f0 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
'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,
|
||||
AdRevenueImpression,
|
||||
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/收益/adn/底层位)
|
||||
const IMPRESSION_COLUMNS: ColumnsType<AdRevenueImpression> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
|
||||
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 },
|
||||
{ title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) },
|
||||
{
|
||||
title: 'adn',
|
||||
dataIndex: 'adn',
|
||||
width: 90,
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '底层代码位ID',
|
||||
dataIndex: 'slot_id',
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
];
|
||||
|
||||
// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 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>
|
||||
);
|
||||
}
|
||||
|
||||
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
|
||||
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
|
||||
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: 110, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
||||
: []),
|
||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||
...(queriedGranularity === 'hour'
|
||||
? [
|
||||
{
|
||||
title: '小时',
|
||||
dataIndex: 'hour',
|
||||
width: 90,
|
||||
render: (h: number | null) =>
|
||||
h == null ? '-' : `${String(h).padStart(2, '0')}:00`,
|
||||
} as ColumnsType<AdRevenueRow>[number],
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: '广告类型',
|
||||
dataIndex: 'ad_type',
|
||||
width: 120,
|
||||
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: 150,
|
||||
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: 120,
|
||||
render: (v: string | null) =>
|
||||
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{ title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' },
|
||||
{
|
||||
title: '预估收益(元)',
|
||||
dataIndex: 'revenue_yuan',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (v: number) => v.toFixed(4),
|
||||
},
|
||||
{
|
||||
title: '应发金币',
|
||||
dataIndex: 'expected_coin',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: number) => <b>{v}</b>,
|
||||
},
|
||||
{ title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' },
|
||||
{
|
||||
title: '一致',
|
||||
dataIndex: 'matched',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (m: boolean) => (m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>),
|
||||
},
|
||||
{
|
||||
title: '底层 ADN',
|
||||
dataIndex: 'adns',
|
||||
render: (v: string[]) =>
|
||||
v.length ? v.map((a) => <Tag key={a}>{a}</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 信息流' },
|
||||
]}
|
||||
/>
|
||||
</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={(r) => `${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
|
||||
expandable={{
|
||||
rowExpandable: (r) =>
|
||||
(r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0,
|
||||
expandedRowRender: (r) => {
|
||||
const imps = r.impression_records ?? [];
|
||||
const recs = r.records ?? [];
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text strong>展示明细</Typography.Text>{' '}
|
||||
<Typography.Text type="secondary">({imps.length} 条)</Typography.Text>
|
||||
<Table<AdRevenueImpression>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="id"
|
||||
columns={IMPRESSION_COLUMNS}
|
||||
dataSource={imps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>发奖明细</Typography.Text>{' '}
|
||||
<Typography.Text type="secondary">({recs.length} 条)</Typography.Text>
|
||||
<Table<AdRevenueRecord>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="record_id"
|
||||
columns={DETAIL_COLUMNS}
|
||||
dataSource={recs}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||||
locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd';
|
||||
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import type { AdminInfo } from '@/lib/types';
|
||||
@@ -16,7 +16,6 @@ const ROLES = [
|
||||
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
|
||||
|
||||
export default function AdminsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [admins, setAdmins] = useState<AdminInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -50,7 +49,7 @@ export default function AdminsPage() {
|
||||
|
||||
const changeRole = (a: AdminInfo, role: string) => {
|
||||
if (role === a.role) return;
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `把 ${a.username} 的角色改为 ${role}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
@@ -66,7 +65,7 @@ export default function AdminsPage() {
|
||||
|
||||
const toggle = (a: AdminInfo) => {
|
||||
const next = a.status === 'active' ? 'disabled' : 'active';
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
|
||||
import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
type: string; // int / int_list / dict_str_int / bool
|
||||
type: string; // int / int_list / dict_str_int
|
||||
help: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: any;
|
||||
@@ -28,7 +28,6 @@ function toEdit(item: ConfigItem): any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function fromEdit(type: string, raw: any): any {
|
||||
if (type === 'int') return Number(raw);
|
||||
if (type === 'bool') return Boolean(raw);
|
||||
if (type === 'int_list') {
|
||||
return String(raw)
|
||||
.split(',')
|
||||
@@ -39,7 +38,6 @@ function fromEdit(type: string, raw: any): any {
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<ConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -111,12 +109,7 @@ export default function ConfigPage() {
|
||||
)}
|
||||
</div>
|
||||
<Space wrap>
|
||||
{item.type === 'bool' ? (
|
||||
<Switch
|
||||
checked={!!edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
/>
|
||||
) : item.type === 'int' ? (
|
||||
{item.type === 'int' ? (
|
||||
<InputNumber
|
||||
value={edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
Spin,
|
||||
Switch,
|
||||
Table,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
@@ -51,7 +51,6 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
|
||||
|
||||
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
|
||||
export default function HomeMarqueeSeeds() {
|
||||
const { message } = App.useApp();
|
||||
const [seeds, setSeeds] = useState<Seed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
@@ -248,7 +249,6 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record<string,
|
||||
|
||||
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
|
||||
export default function HomeStatsConfig() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<StatItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [edits, setEdits] = useState<Record<string, Edit>>({});
|
||||
@@ -321,7 +321,7 @@ export default function HomeStatsConfig() {
|
||||
// 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
|
||||
const rel = checkRelations(items, edits);
|
||||
if (rel && rel.warnings.length > 0) {
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: '数据关系校验',
|
||||
width: 480,
|
||||
okText: '仍要这样设置',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { App, Button, Popconfirm, Space, Table } from 'antd';
|
||||
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
@@ -13,7 +13,6 @@ const dt = (v: string) => formatUtcTime(v);
|
||||
const EMPTY: Record<string, unknown> = {};
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。
|
||||
const { items, loading, reload } = useCursorList<DeviceOnboardingItem>(
|
||||
'/admin/api/onboarding/devices',
|
||||
@@ -32,7 +31,7 @@ export default function DevicesPage() {
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: '确认全部重设新手引导?',
|
||||
content:
|
||||
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
|
||||
|
||||
@@ -5,25 +5,26 @@ import type { Key } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
DatePicker,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { Feedback } from '@/lib/types';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
|
||||
@@ -33,7 +34,6 @@ const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
|
||||
type SortField = 'id' | 'created_at';
|
||||
|
||||
export default function FeedbacksPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [userId, setUserId] = useState('');
|
||||
@@ -46,8 +46,10 @@ export default function FeedbacksPage() {
|
||||
|
||||
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||
const filterKey = JSON.stringify(filters);
|
||||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||
usePagedList<Feedback>('/admin/api/feedbacks', filters);
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
|
||||
'/admin/api/feedbacks',
|
||||
filters,
|
||||
);
|
||||
|
||||
const canHandle = canDo(['operator']);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
@@ -114,7 +116,7 @@ export default function FeedbacksPage() {
|
||||
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
|
||||
content: '仅对选中的待处理(new)反馈生效',
|
||||
onOk: async () => {
|
||||
@@ -134,22 +136,7 @@ export default function FeedbacksPage() {
|
||||
const columns: ColumnsType<Feedback> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
width: 360,
|
||||
render: (v: string) =>
|
||||
v ? (
|
||||
<Paragraph
|
||||
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
ellipsis={{ rows: 3, expandable: true, symbol: '展开', tooltip: true }}
|
||||
>
|
||||
{v}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content' },
|
||||
{
|
||||
title: '截图',
|
||||
dataIndex: 'images',
|
||||
@@ -270,16 +257,14 @@ export default function FeedbacksPage() {
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条反馈`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
pagination={false}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
FlagOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MobileOutlined,
|
||||
@@ -28,7 +28,7 @@ const MENU = [
|
||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告数据' },
|
||||
{ key: '/ad-audit', icon: <FundProjectionScreenOutlined />, label: '金币审计' },
|
||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Image,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
@@ -57,7 +57,6 @@ function statusTag(status: string) {
|
||||
}
|
||||
|
||||
export default function PriceReportsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [activeStatus, setActiveStatus] = useState('pending');
|
||||
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
|
||||
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
|
||||
@@ -89,7 +88,7 @@ export default function PriceReportsPage() {
|
||||
};
|
||||
|
||||
const approve = (r: PriceReport) => {
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: '确认通过该上报?',
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
content: (
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Result,
|
||||
Row,
|
||||
Space,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
@@ -26,7 +27,6 @@ import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModa
|
||||
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const params = useParams<{ id: string }>();
|
||||
const uid = Number(params.id);
|
||||
const [data, setData] = useState<UserOverview | null>(null);
|
||||
@@ -56,7 +56,7 @@ export default function UserDetailPage() {
|
||||
const toggleStatus = () => {
|
||||
if (!data) return;
|
||||
const next = data.user.status === 'active' ? 'disabled' : 'active';
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Key } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
|
||||
import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
@@ -22,7 +22,6 @@ type SortField = 'id' | 'created_at' | 'last_login_at';
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const { message, modal } = App.useApp();
|
||||
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
|
||||
const [phone, setPhone] = useState('');
|
||||
@@ -98,7 +97,7 @@ export default function UsersPage() {
|
||||
|
||||
const toggleStatus = (u: UserListItem) => {
|
||||
const next = u.status === 'active' ? 'disabled' : 'active';
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
@@ -114,7 +113,7 @@ export default function UsersPage() {
|
||||
|
||||
const toggleDebugTrace = (u: UserListItem) => {
|
||||
const next = !u.debug_trace_enabled;
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
|
||||
content: next
|
||||
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
|
||||
@@ -157,7 +156,7 @@ export default function UsersPage() {
|
||||
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认${label} ${targets.length} 个用户?`,
|
||||
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
|
||||
okButtonProps: { danger: target === 'disabled' },
|
||||
@@ -178,7 +177,7 @@ export default function UsersPage() {
|
||||
message.warning('没有需要变更的选中用户');
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认${label} ${targets.length} 个用户?`,
|
||||
onOk: () =>
|
||||
runBulk(targets, label, (u) =>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import {
|
||||
App,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -169,7 +169,6 @@ function bulkResultText(action: string, result: WithdrawBulkResult) {
|
||||
}
|
||||
|
||||
export default function WithdrawsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [activeStatus, setActiveStatus] = useState('reviewing');
|
||||
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
|
||||
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
|
||||
@@ -306,7 +305,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const approve = (o: WithdrawOrder) => {
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
|
||||
content: (
|
||||
<div>
|
||||
@@ -369,7 +368,7 @@ export default function WithdrawsPage() {
|
||||
}
|
||||
const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0);
|
||||
let confirmText = '';
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认批量通过 ${selectedReviewing.length} 笔提现?`,
|
||||
content: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
@@ -407,7 +406,7 @@ export default function WithdrawsPage() {
|
||||
message.warning('请选择打款中的提现单');
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
|
||||
content: '会逐笔向微信查单,并按最新状态归一化。',
|
||||
okText: '批量刷新查单',
|
||||
@@ -429,7 +428,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const retry = (o: WithdrawOrder) => {
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
|
||||
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
|
||||
okText: '刷新查单',
|
||||
@@ -451,7 +450,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const reconcile = () => {
|
||||
modal.confirm({
|
||||
Modal.confirm({
|
||||
title: '批量对账?',
|
||||
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
|
||||
okText: '开始对账',
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { App, Button, Card, Form, Input } from 'antd';
|
||||
import { Button, Card, Form, Input, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { setAuth } from '@/lib/auth';
|
||||
import type { LoginResponse } from '@/lib/types';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
|
||||
+3
-15
@@ -5,27 +5,15 @@
|
||||
// 必须在引入 antd 之前 import。
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||
import { App, ConfigProvider } from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AntdRegistry>
|
||||
{/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */}
|
||||
{/* 全局主题:统一圆角到 8、页面底色用偏冷的浅灰,让白色卡片更有层次(全站生效) */}
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
wave={{ disabled: true }}
|
||||
theme={{
|
||||
token: {
|
||||
borderRadius: 8,
|
||||
colorBgLayout: '#f0f2f5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* antd <App> 容器:让 message/notification/Modal 能通过 App.useApp() 读到 ConfigProvider
|
||||
的动态主题/locale,消除 "Static function can not consume context" 告警(全站生效) */}
|
||||
<App>{children}</App>
|
||||
<ConfigProvider locale={zhCN} wave={{ disabled: true }}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</AntdRegistry>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
|
||||
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd';
|
||||
import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { yuan } from '@/lib/format';
|
||||
import type { UserOverview } from '@/lib/types';
|
||||
@@ -47,7 +47,6 @@ function useBalances(userId: number | undefined, open: boolean) {
|
||||
}
|
||||
|
||||
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||
const balances = useBalances(user?.id, open);
|
||||
@@ -111,7 +110,6 @@ export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
}
|
||||
|
||||
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||
const balances = useBalances(user?.id, open);
|
||||
|
||||
+21
-53
@@ -188,72 +188,40 @@ export interface AuditLog {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 广告收益报表聚合行下钻的单条展示明细
|
||||
export interface AdRevenueImpression {
|
||||
id: number;
|
||||
created_at: string;
|
||||
ecpm: string; // 分/千次展示
|
||||
revenue_yuan: number; // 本次展示预估收益(元)
|
||||
adn: string | null;
|
||||
slot_id: string | null; // 底层 mediation rit
|
||||
}
|
||||
|
||||
// 广告收益报表聚合行下钻的单条发奖复算明细
|
||||
export interface AdRevenueRecord {
|
||||
export interface AdCoinAuditRow {
|
||||
scene: string; // reward_video / feed
|
||||
record_id: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
status: string; // granted / capped / ecpm_missing / too_short / closed_early
|
||||
status: string; // granted / capped / ecpm_missing
|
||||
ecpm: string | null;
|
||||
ecpm_factor: number | null; // 因子1
|
||||
units: number; // 份数
|
||||
ecpm_factor: number | null;
|
||||
units: number;
|
||||
lt_index_start: number | null;
|
||||
lt_index_end: number | null;
|
||||
lt_factor_start: number | null; // 因子2
|
||||
lt_factor_start: number | null;
|
||||
lt_factor_end: number | null;
|
||||
expected_coin: number;
|
||||
actual_coin: number;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响)
|
||||
export interface AdRevenueDaily {
|
||||
date: string; // 北京时间 YYYY-MM-DD
|
||||
impressions: number;
|
||||
revenue_yuan: number;
|
||||
expected_coin: number;
|
||||
actual_coin: number;
|
||||
export interface AdCoinFormula {
|
||||
description: string;
|
||||
coin_per_yuan: number;
|
||||
ecpm_unit: string;
|
||||
feed_unit_seconds: number;
|
||||
ecpm_factor_tiers: [number, number, number | null][];
|
||||
lt_factor_tiers: [number, number, number | null][];
|
||||
}
|
||||
|
||||
// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合
|
||||
export interface AdRevenueRow {
|
||||
report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD
|
||||
user_id: number;
|
||||
ad_type: string; // reward_video / feed / draw
|
||||
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
|
||||
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
|
||||
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
|
||||
impressions: number; // 展示条数(轮播每条各计一次)
|
||||
revenue_yuan: number; // 收益(元),预估口径
|
||||
expected_coin: number; // 应发金币(公式复算,与金币审计同源)
|
||||
actual_coin: number; // 实发金币(实际入账)
|
||||
matched: boolean; // 该组应发==实发
|
||||
adns: string[]; // 底层填充 ADN 子渠道集合
|
||||
impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻)
|
||||
records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻)
|
||||
}
|
||||
|
||||
export interface AdRevenueReport {
|
||||
date_from: string; // 起始日 北京时间 YYYY-MM-DD
|
||||
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
|
||||
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
||||
total: number; // 聚合组总数(全量,不受 limit 影响)
|
||||
truncated: boolean;
|
||||
total_impressions: number;
|
||||
total_revenue_yuan: number;
|
||||
total_expected_coin: number;
|
||||
total_actual_coin: number;
|
||||
mismatch_count: number; // 应发≠实发的组数
|
||||
items: AdRevenueRow[];
|
||||
export interface AdCoinAudit {
|
||||
date: string;
|
||||
formula: AdCoinFormula;
|
||||
total: number; // 全量条数(不受 limit/only_mismatch 影响)
|
||||
mismatch_count: number; // 全量不一致条数
|
||||
truncated: boolean; // 展示集是否被 limit 截断
|
||||
items: AdCoinAuditRow[];
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
|
||||
Reference in New Issue
Block a user