Compare commits

..

1 Commits

Author SHA1 Message Date
linkeyu 1d28358f07 修复:统一提现审核状态汇总与列表口径 2026-07-27 15:44:03 +08:00
7 changed files with 27 additions and 161 deletions
+4 -6
View File
@@ -735,7 +735,7 @@ export default function AdRevenueReportPage() {
: `${data.date_from} ~ ${data.date_to}`;
// 第二行大盘「分广告类型」:看视频包含福利视频与提现视频;
// eCPM 优先使用后端按真实展示加权的经营分类口径;兼容旧后端时才用合并收益÷展示数回退
// eCPM 使用合并后的总收益 ÷ 总展示数 × 1000,避免只读取 reward_video 而漏算提现视频
const drawStat = data?.type_stats?.draw;
const rewardVideoStat = data?.type_stats?.reward_video;
const withdrawalVideoStat = data?.type_stats?.withdrawal_video;
@@ -745,10 +745,8 @@ export default function AdRevenueReportPage() {
revenue_yuan:
(rewardVideoStat?.revenue_yuan ?? 0) + (withdrawalVideoStat?.revenue_yuan ?? 0),
};
const drawEcpmStat = data?.category_stats?.draw ?? drawStat;
const videoEcpmStat = data?.category_stats?.video ?? videoStat;
const ecpmOf = (s?: AdRevenueTypeStat) =>
s?.ecpm_yuan ?? (s && s.impressions > 0 ? (s.revenue_yuan / s.impressions) * 1000 : 0);
s && s.impressions > 0 ? (s.revenue_yuan / s.impressions) * 1000 : 0;
// 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。
const items = data?.items ?? [];
@@ -1062,7 +1060,7 @@ export default function AdRevenueReportPage() {
<Statistic title="Draw 信息流收益(元)" value={drawStat?.revenue_yuan ?? 0} precision={4} />
</Col>
<Col span={4}>
<Statistic title="Draw 信息流 eCPM(元/千次)" value={ecpmOf(drawEcpmStat)} precision={2} />
<Statistic title="Draw 信息流 eCPM(元/千次)" value={ecpmOf(drawStat)} precision={2} />
</Col>
<Col span={4}>
<Statistic title="Draw 信息流条数" value={drawStat?.impressions ?? 0} />
@@ -1071,7 +1069,7 @@ export default function AdRevenueReportPage() {
<Statistic title="看视频收益(元)" value={videoStat.revenue_yuan} precision={4} />
</Col>
<Col span={4}>
<Statistic title="看视频 eCPM(元/千次)" value={ecpmOf(videoEcpmStat)} precision={2} />
<Statistic title="看视频 eCPM(元/千次)" value={ecpmOf(videoStat)} precision={2} />
</Col>
<Col span={4}>
<Statistic title="看视频条数" value={videoStat.impressions} />
+4 -54
View File
@@ -46,41 +46,6 @@ const fmtTok = (inTok: number | null, outTok: number | null) =>
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
const fmtRate = (value: number | null) => (value == null ? '-' : `${(value * 100).toFixed(1)}%`);
function deviceName(record: ComparisonRecordListItem): string {
return record.device_model_name || record.device_model || '-';
}
function romName(record: ComparisonRecordListItem): string {
return [
record.rom_vendor,
record.rom_name,
record.rom_version != null ? String(record.rom_version) : null,
]
.filter(Boolean)
.join(' ') || '-';
}
function renderDevice(record: ComparisonRecordListItem): ReactNode {
const readableName = deviceName(record);
const showRawModel =
record.device_model_name
&& record.device_model
&& record.device_model_name !== record.device_model;
return (
<Space direction="vertical" size={0}>
<span>{readableName}</span>
{showRawModel ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{record.device_model}
</Typography.Text>
) : null}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{romName(record)}
</Typography.Text>
</Space>
);
}
// LLM 价格快照只展示 prices 里各模型的单价(元/每百万 token),忽略 mode/_source 等元字段。
function llmPriceRows(snapshot: Record<string, unknown> | null): string[] {
const prices = snapshot?.prices;
@@ -333,14 +298,6 @@ export default function ComparisonRecordsPage() {
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
),
},
{
title: '是否下单',
dataIndex: 'ordered',
width: 84,
render: (ordered: boolean) => (
<Tag color={ordered ? 'green' : 'default'}>{ordered ? '已下单' : '未下单'}</Tag>
),
},
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
{
title: 'TOKEN',
@@ -351,9 +308,9 @@ export default function ComparisonRecordsPage() {
{
title: '机型/ROM',
key: 'device',
width: 180,
width: 150,
ellipsis: true,
render: (_, r) => renderDevice(r),
render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-',
},
{
title: 'traceid',
@@ -542,15 +499,8 @@ export default function ComparisonRecordsPage() {
<Card size="small" title="设备环境">
<Descriptions size="small" column={2}>
<Descriptions.Item label="机型">
{deviceName(detail)}
{detail.device_model_name && detail.device_model_name !== detail.device_model
? `${[detail.device_manufacturer, detail.device_model].filter(Boolean).join(' · ')}`
: detail.device_manufacturer
? `${detail.device_manufacturer}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="ROM">{romName(detail)}</Descriptions.Item>
<Descriptions.Item label="机型">{detail.device_model || '-'}{detail.device_manufacturer || '-'}</Descriptions.Item>
<Descriptions.Item label="ROM">{[detail.rom_vendor, detail.rom_name, detail.rom_version].filter(Boolean).join(' ') || '-'}</Descriptions.Item>
<Descriptions.Item label="Android">{detail.android_version || '-'}SDK {detail.android_sdk ?? '-'}</Descriptions.Item>
<Descriptions.Item label="App 版本">{detail.app_version || '-'}{detail.app_version_code ?? '-'}</Descriptions.Item>
<Descriptions.Item label="源 App 版本" span={2}>{detail.source_app_version || '-'}</Descriptions.Item>
+5 -32
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
@@ -35,7 +35,6 @@ import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const REWARD_MAX = 10000;
const REVIEW_PAGE_POLL_INTERVAL_MS = 60_000;
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
@@ -197,10 +196,6 @@ export default function FeedbacksPage() {
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<Feedback>('/admin/api/feedbacks', filters);
const reloadRef = useRef(reload);
useEffect(() => {
reloadRef.current = reload;
}, [reload]);
const canReview = canDo(['operator']);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
@@ -218,39 +213,17 @@ export default function FeedbacksPage() {
}, [items]);
const [summary, setSummary] = useState<FeedbackSummary | null>(null);
const loadSummary = useCallback(async () => {
const loadSummary = async () => {
try {
const { data } = await api.get<FeedbackSummary>('/admin/api/feedbacks/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
}, []);
const refreshPageData = useCallback(() => {
void reloadRef.current();
void loadSummary();
}, [loadSummary]);
};
useEffect(() => {
void loadSummary();
const onWindowFocus = () => refreshPageData();
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') refreshPageData();
};
const pollTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') refreshPageData();
}, REVIEW_PAGE_POLL_INTERVAL_MS);
window.addEventListener('focus', onWindowFocus);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.clearInterval(pollTimer);
window.removeEventListener('focus', onWindowFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [loadSummary, refreshPageData]);
loadSummary();
}, []);
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
+7 -26
View File
@@ -37,8 +37,6 @@ import type {
WithdrawSummary,
} from '@/lib/types';
const REVIEW_BADGE_POLL_INTERVAL_MS = 60_000;
const { Sider, Header, Content } = Layout;
type NavItem = {
@@ -164,39 +162,22 @@ export default function MainLayout({ children }: { children: React.ReactNode })
};
const refreshSafely = (key: ReviewBadgeKey) => refresh(key).catch(() => {});
const refreshAll = () => {
void Promise.all([
refreshSafely('/withdraws'),
refreshSafely('/invite-withdraws'),
refreshSafely('/price-reports'),
refreshSafely('/feedbacks'),
]);
};
refreshAll();
void Promise.all([
refreshSafely('/withdraws'),
refreshSafely('/invite-withdraws'),
refreshSafely('/price-reports'),
refreshSafely('/feedbacks'),
]);
const onReviewCompleted = (event: Event) => {
const key = (event as CustomEvent<ReviewBadgeKey>).detail;
if (key) void refreshSafely(key);
};
const onWindowFocus = () => refreshAll();
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') refreshAll();
};
const pollTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') refreshAll();
}, REVIEW_BADGE_POLL_INTERVAL_MS);
window.addEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
window.addEventListener('focus', onWindowFocus);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.clearInterval(pollTimer);
window.removeEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
window.removeEventListener('focus', onWindowFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [admin, pathname]);
}, [admin]);
if (!admin) return null; // 守卫期间不闪烁内容
+5 -32
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import {
App,
Button,
@@ -30,7 +30,6 @@ import type { PriceReport, PriceReportSummary } from '@/lib/types';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text } = Typography;
const REVIEW_PAGE_POLL_INTERVAL_MS = 60_000;
// 后端 created_at/reviewed_at 存的是北京 wall-clock(naive,同 savings/comparison),
// 直接本地解析、不加 Z(否则会差 8h)。
@@ -151,10 +150,6 @@ export default function PriceReportsPage() {
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const reloadRef = useRef(reload);
useEffect(() => {
reloadRef.current = reload;
}, [reload]);
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
@@ -186,39 +181,17 @@ export default function PriceReportsPage() {
setSelectedRowKeys((keys) => keys.filter((id) => visiblePendingIds.has(id)));
}, [items]);
const loadSummary = useCallback(async () => {
const loadSummary = async () => {
try {
const { data } = await api.get<PriceReportSummary>('/admin/api/price-reports/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
}, []);
const refreshPageData = useCallback(() => {
void reloadRef.current();
void loadSummary();
}, [loadSummary]);
};
useEffect(() => {
void loadSummary();
const onWindowFocus = () => refreshPageData();
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') refreshPageData();
};
const pollTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') refreshPageData();
}, REVIEW_PAGE_POLL_INTERVAL_MS);
window.addEventListener('focus', onWindowFocus);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.clearInterval(pollTimer);
window.removeEventListener('focus', onWindowFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [loadSummary, refreshPageData]);
loadSummary();
}, []);
const refreshAfterChange = () => {
reload();
@@ -570,7 +570,6 @@ export function WithdrawReviewPage({ mode = 'other' }: { mode?: WithdrawReviewMo
},
{
title: '失败/拒绝原因',
key: 'fail_reason',
dataIndex: 'fail_reason',
ellipsis: true,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
@@ -648,10 +647,6 @@ export function WithdrawReviewPage({ mode = 'other' }: { mode?: WithdrawReviewMo
},
},
];
const visibleColumns =
activeStatus === 'pending' || activeStatus === 'success'
? columns.filter((column) => column.key !== 'fail_reason')
: columns;
const inviteeColumns: ColumnsType<InviteeDetail> = [
{ title: '手机号', dataIndex: 'phone', width: 130 },
@@ -950,7 +945,7 @@ export function WithdrawReviewPage({ mode = 'other' }: { mode?: WithdrawReviewMo
}
: undefined
}
columns={visibleColumns}
columns={columns}
dataSource={items}
loading={loading}
pagination={{
+1 -5
View File
@@ -457,7 +457,6 @@ export interface ComparisonRecordListItem {
source_price_cents: number | null;
best_price_cents: number | null;
saved_amount_cents: number | null;
ordered: boolean;
total_ms: number | null;
step_count: number | null;
llm_call_count: number | null;
@@ -466,10 +465,8 @@ export interface ComparisonRecordListItem {
output_tokens: number | null;
llm_cost_yuan: number | null; // 后端按「当时价」冻结的实际成本(元);旧记录 null → 「成本」列回退估算
device_model: string | null;
device_model_name: string | null;
rom_vendor: string | null;
rom_name: string | null;
rom_version: number | null;
android_version: string | null;
app_version: string | null;
ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元)
@@ -520,6 +517,7 @@ export interface ComparisonRecordDetail extends ComparisonRecordListItem {
comparison_results: Record<string, unknown>[];
skipped_dish_names: string[];
device_manufacturer: string | null;
rom_version: number | null;
android_sdk: number | null;
app_version_code: number | null;
source_app_version: string | null;
@@ -583,7 +581,6 @@ export interface AdRevenueHourly {
export interface AdRevenueTypeStat {
impressions: number; // 该类型展示条数合计
revenue_yuan: number; // 该类型预估收益合计(元)
ecpm_yuan?: number; // 按真实展示次数加权的 SDK eCPM(元/千次)
}
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
@@ -623,7 +620,6 @@ export interface AdRevenueReport {
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
category_stats?: Record<string, AdRevenueTypeStat>; // draw=draw+历史feedvideo=福利视频+提现视频
// 按信息流场景小计(comparison/coupon/welfare;全量,不受分页截断)。数据大盘「领券广告/比价广告」卡
// 优先读它:2026-07-02 后端起 items 不再含信息流逐条展示行(唯一带收益+场景的行),按 items 现算恒 0。
// 可选(`?.` 探测)以兼容未升级后端,缺失时回退 items 现算。