diff --git a/src/app/(main)/ad-audit/page.tsx b/src/app/(main)/ad-audit/page.tsx new file mode 100644 index 0000000..ef9c4ef --- /dev/null +++ b/src/app/(main)/ad-audit/page.tsx @@ -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 = { + reward_video: { color: 'blue', label: '看视频' }, + feed: { color: 'purple', label: '信息流' }, +}; + +const STATUS_TAG: Record = { + 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()); + const [userId, setUserId] = useState(null); + const [scene, setScene] = useState(); + const [limit, setLimit] = useState(100); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 = [ + { + title: '场景', + dataIndex: 'scene', + width: 90, + render: (s: string) => { + const t = SCENE_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { title: '时间', dataIndex: 'created_at', render: dt, width: 175 }, + { title: '用户', dataIndex: 'user_id', width: 70 }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (s: string) => {s}, + }, + { 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) => {v}, + }, + { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, + { + title: '一致', + dataIndex: 'matched', + width: 70, + render: (m: boolean) => + m ? : ✗ 不符, + }, + ]; + + return ( +
+

看广告金币审计

+ + 把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。 + + + {data && ( + + 金币公式:{' '} + {data.formula.description} +
+
+ eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '} + {data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。 +
+
+ 因子1(按 eCPM 元判档,阈值单位:元): + {data.formula.ecpm_factor_tiers + .map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`) + .join(' | ')} +
+
+ 因子2(LT 累计条数): + {data.formula.lt_factor_tiers + .map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`) + .join(' | ')} +
+
+
+ )} + + + d && setDate(d)} allowClear={false} /> + setUserId(v ?? null)} + style={{ width: 140 }} + /> + ({ value: n, label: `${n} 条` }))} + /> + + + + {data && ( +
+ {data.mismatch_count > 0 ? ( + + ) : ( + + )} +
+ )} + + `${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')} + /> + + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index f888089..8672cc2 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -6,6 +6,7 @@ import { DashboardOutlined, FileSearchOutlined, FlagOutlined, + FundProjectionScreenOutlined, LogoutOutlined, MessageOutlined, MoneyCollectOutlined, @@ -25,6 +26,7 @@ const MENU = [ { key: '/withdraws', icon: , label: '提现管理' }, { key: '/price-reports', icon: , label: '上报审核' }, { key: '/feedbacks', icon: , label: '反馈工单' }, + { key: '/ad-audit', icon: , label: '金币审计' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, { key: '/audit-logs', icon: , label: '审计日志' }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 01bcb1c..80070cc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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;