Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 631b243d4b | |||
| bd2daedc44 |
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
|
||||
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 [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,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, userId, scene, limit]);
|
||||
|
||||
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: dt, 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} 条` }))}
|
||||
/>
|
||||
<Button type="primary" onClick={load} loading={loading}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{data && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{data.mismatch_count > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="success"
|
||||
message={`共 ${data.total} 条,全部按公式发放(无不一致)`}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
FlagOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MoneyCollectOutlined,
|
||||
@@ -25,6 +26,7 @@ const MENU = [
|
||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||
{ key: '/feedbacks', icon: <MessageOutlined />, 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: '审计日志' },
|
||||
|
||||
@@ -180,6 +180,41 @@ export interface AuditLog {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdCoinAuditRow {
|
||||
scene: string; // reward_video / feed
|
||||
record_id: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
status: string; // granted / capped / ecpm_missing
|
||||
ecpm: string | null;
|
||||
ecpm_factor: number | null;
|
||||
units: number;
|
||||
lt_index_start: number | null;
|
||||
lt_index_end: number | null;
|
||||
lt_factor_start: number | null;
|
||||
lt_factor_end: number | null;
|
||||
expected_coin: number;
|
||||
actual_coin: number;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
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 AdCoinAudit {
|
||||
date: string;
|
||||
formula: AdCoinFormula;
|
||||
total: number;
|
||||
mismatch_count: number;
|
||||
items: AdCoinAuditRow[];
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
users: {
|
||||
total: number;
|
||||
|
||||
Reference in New Issue
Block a user