Compare commits

...

7 Commits

Author SHA1 Message Date
lowmaster-chen 1db0c3f834 fix: 同步数据大盘活跃用户口径文案 2026-06-30 19:49:49 +08:00
lowmaster-chen aaea458f1a feat: 优化数据大盘环比展示并移除总用户卡 2026-06-30 15:50:32 +08:00
lowmaster-chen 464d8dd183 feat: 补齐数据大盘京东 CPS 与金币展示
改了什么:数据大盘总收入/CPS 收入纳入京东 CPS;京东 CPS 卡片展示有效/无效订单;刷新按钮改为同步全部 CPS 订单;ARPU 展示产品确认的昨日活跃用户分母;激励视频金币展示真实值并标注已计入领券奖励。

验证方式:npx tsc --noEmit;浏览器验证 /dashboard 已显示京东 CPS、ARPU 分母和激励视频金币。
2026-06-30 10:23:49 +08:00
marco 6a3ce5d340 fix(types): 同步 DashboardOverview/AdRevenueReport 类型, 修数据大盘页编译失败
新版数据大盘页(dashboard/page.tsx, #24)引用的后端字段在 types.ts 未同步,
next build 类型检查失败致 admin-web main 无法构建/部署(0.2.6 发车 deploy 卡在此步):
- DashboardOverview 补 period 嵌套结构(date_from/to + users/comparison/coins/cash/trend),
  对齐已部署后端 app/admin/schemas/dashboard.py 的 DashboardPeriod
- AdRevenueReport 补可选 by_ad_type(按广告类型富小计含金币明细);后端当前不下发,
  页面用 ?. 探测、缺失时按 items 现算兜底, 此处仅补类型声明让其编译通过

均为类型声明同步, 不改任何运行逻辑;本地 next build 已通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 02:39:21 +08:00
zhuzihao 98cea13d51 用户反馈/低价审核 admin 页改版(重命名+二维码挪位+统计修复+列增强) (#28)
- 重命名:菜单与页面标题「反馈工单→用户反馈」「上报审核→低价审核」
- 反馈页「加群二维码」卡挪到系统配置页,新增「反馈二维码」tab
- 修复反馈四态统计恒为 0:对接后端新增的 /admin/api/feedbacks/summary
- 两页表格增强:用户列拆为 用户ID/手机号/用户昵称;新增 提交版本号、机型/OS版本 列;
  点手机号弹抽屉看该用户全部记录,抽屉顶部汇总 提交/上报总计 + 奖励总计
- 低价审核:提交时间列可排序、审核结果与操作拆两列、顶部统计改卡片式、
  Trace 列对齐比价记录页(有 trace_url 即可点)
- 修复反馈时间显示快 8 小时:反馈时间为北京 wall-clock,改用 formatWallTime

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #28
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-29 23:11:07 +08:00
chenshuobo d7129186c5 feat: 调整后台左侧导航分组层级 (#27)
改了什么:按看板、奖励审核、数据配置重新组织左侧导航,二级入口展示在一级标题右下方;补齐主干大盘页缺失的类型字段,保证类型检查通过。

验证方式:npx tsc --noEmit;浏览器检查 /dashboard 左侧导航顺序和二级缩进。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #27
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-29 14:52:46 +08:00
zhuzihao a1d9923f33 feat(ad-revenue): 收益报表大盘展示穿山甲后台收益(预估/收益API/趋势线) (#26)
- 核心指标新增「穿山甲后台收益(T+1)」区:穿山甲预估收益、收益API、客户端预估偏差;
  按天趋势图加一条绿色「穿山甲后台收益」线(数据来自后端 daily[].pangle_*)。
- 仅在全量视图(未按用户/类型/场景筛选)展示;非全量或未同步时显示「-」并提示。
- 收益口径说明补充穿山甲后台收益来源;types.ts 同步新增 total_pangle_revenue_yuan /
  total_pangle_api_revenue_yuan / pangle_revenue_available / daily[].pangle_*。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #26
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-29 09:53:52 +08:00
10 changed files with 992 additions and 242 deletions
+118 -6
View File
@@ -139,18 +139,21 @@ const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
},
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=客户端预估收益元(右轴),
// 绿线=穿山甲后台预估收益元(右轴,仅按天且有数据时出现)。x 轴按传入点序。
const CHART_BAR = '#69b1ff';
const CHART_LINE = '#fa8c16';
const CHART_LINE2 = '#52c41a'; // 穿山甲后台收益线
interface TrendPoint {
label: string; // x 轴刻度文案(小时数 / MM-DD)
tip: string; // hover 原生 tooltip 全文
impressions: number;
revenue: number;
pangleRevenue: number | null; // 穿山甲后台预估收益(元);无则 null(不画绿线点)
}
// 按小时聚合(023),数据源是 hourly(后端全量聚合,不受分页影响)
// 按小时聚合(023),数据源是 hourly(后端全量聚合,不受分页影响)。穿山甲为天级,小时视图无该线。
function aggregateHourly(rows: AdRevenueHourly[]): TrendPoint[] {
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
for (const r of rows) {
@@ -163,10 +166,12 @@ function aggregateHourly(rows: AdRevenueHourly[]): TrendPoint[] {
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)}`,
impressions: b.impressions,
revenue: b.revenue,
pangleRevenue: null,
}));
}
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
// 穿山甲收益(pangle_revenue_yuan)为 null 时该日不画绿线点(T+1 未出/非全量视图)。
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
const map = new Map(daily.map((d) => [d.date, d]));
const out: TrendPoint[] = [];
@@ -177,11 +182,14 @@ function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[
const d = map.get(ds);
const impressions = d?.impressions ?? 0;
const revenue = d?.revenue_yuan ?? 0;
const pangleRevenue = d?.pangle_revenue_yuan ?? null;
const pangleTip = pangleRevenue != null ? ` · 穿山甲收益 ${pangleRevenue.toFixed(4)}` : '';
out.push({
label: ds.slice(5),
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}`,
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}${pangleTip}`,
impressions,
revenue,
pangleRevenue,
});
cur = cur.add(1, 'day');
guard += 1;
@@ -192,7 +200,11 @@ function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[
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 maxRev = Math.max(
1e-9,
...points.map((p) => p.revenue),
...points.map((p) => p.pangleRevenue ?? 0),
);
const W = 960;
const H = 280;
@@ -248,6 +260,24 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
<title>{p.tip}</title>
</circle>
))}
{/* 穿山甲后台收益线(绿,仅非 null 的天画;按天且已同步 T+1 数据时出现) */}
{(() => {
const pp = points
.map((p, i) => ({ i, v: p.pangleRevenue, tip: p.tip }))
.filter((x): x is { i: number; v: number; tip: string } => x.v != null);
if (pp.length === 0) return null;
const pangleLine = pp.map((x) => `${cx(x.i)},${revY(x.v)}`).join(' ');
return (
<g>
<polyline points={pangleLine} fill="none" stroke={CHART_LINE2} strokeWidth={2} />
{pp.map((x) => (
<circle key={`p-${x.i}`} cx={cx(x.i)} cy={revY(x.v)} r={2.5} fill={CHART_LINE2}>
<title>{x.tip}</title>
</circle>
))}
</g>
);
})()}
{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">
@@ -500,6 +530,11 @@ export default function AdRevenueReportPage() {
广(onAdShow) eCPM ( ÷1000
),<b>广</b>;穿/,,
<b>穿</b> 0广ID / ,
<br />
<br />
<b>穿(T+1)</b>穿 GroMore API(,):
<b>穿</b>= revenue<b>API</b>= ADN 穿//,
<b></b>(//);,
</div>
}
>
@@ -666,6 +701,69 @@ export default function AdRevenueReportPage() {
</Typography.Text>
</Col>
</Row>
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
<Tooltip title="来自穿山甲 GroMore 数据 API(后台结算口径,T+1 次日出数)。穿山甲不提供分用户/类型/场景维度,故仅在「全量视图」(未按用户/类型/场景筛选)展示;按代码位×应用×日期汇总。revenue=预估收益、收益API=各 ADN 回传更接近结算。">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
穿(T+1)
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</Typography.Text>
</Tooltip>
</Divider>
{data.pangle_revenue_available ? (
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic
title="穿山甲预估收益(元)"
value={data.total_pangle_revenue_yuan ?? 0}
precision={4}
/>
</Col>
<Col flex="1 1 0">
<Statistic
title="穿山甲收益API(元)"
value={data.total_pangle_api_revenue_yuan ?? '-'}
precision={data.total_pangle_api_revenue_yuan == null ? undefined : 4}
/>
{data.total_pangle_api_revenue_yuan == null && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Reporting /
</Typography.Text>
)}
</Col>
<Col flex="1 1 0">
<Statistic
title={
<Tooltip title="客户端预估相对穿山甲预估的高估幅度 =(客户端预估 − 穿山甲预估)÷ 穿山甲预估;客户端按 onAdShow 计、不滤无效曝光,通常偏高">
<span>
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</span>
</Tooltip>
}
value={
data.total_pangle_revenue_yuan && data.total_pangle_revenue_yuan > 0
? ((data.total_revenue_yuan - data.total_pangle_revenue_yuan) /
data.total_pangle_revenue_yuan) *
100
: '-'
}
precision={
data.total_pangle_revenue_yuan && data.total_pangle_revenue_yuan > 0
? 1
: undefined
}
suffix={
data.total_pangle_revenue_yuan && data.total_pangle_revenue_yuan > 0 ? '%' : ''
}
/>
</Col>
</Row>
) : (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
本视图无穿山甲后台收益:(//)
穿 T+1 , <Typography.Text code>scripts/sync_pangle_revenue</Typography.Text>
</Typography.Text>
)}
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
广
@@ -743,8 +841,22 @@ export default function AdRevenueReportPage() {
verticalAlign: 'middle',
}}
/>
()
()
</span>
{queriedMultiDay && (
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 14,
borderTop: `2px solid ${CHART_LINE2}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
穿()
</span>
)}
</Space>
}
>
@@ -12,7 +12,7 @@ const { Text } = Typography;
const ACCEPT = ['image/jpeg', 'image/png', 'image/webp'];
/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「反馈工单」页的一个区块。 */
/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「系统配置 → 反馈二维码」tab 的一个区块。 */
export default function FeedbackQrConfig() {
const [cfg, setCfg] = useState<QrConfig | null>(null);
const [loading, setLoading] = useState(true);
+2
View File
@@ -5,6 +5,7 @@ import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tabs, Tag,
import { api, errMsg } from '@/lib/api';
import HomeMarqueeSeeds from './HomeMarqueeSeeds';
import HomeStatsConfig from './HomeStatsConfig';
import FeedbackQrConfig from './FeedbackQrConfig';
interface ConfigItem {
key: string;
@@ -162,6 +163,7 @@ export default function ConfigPage() {
),
},
{ key: 'welfare', label: '福利页', children: welfareConfig },
{ key: 'feedback-qr', label: '反馈二维码', children: <FeedbackQrConfig /> },
]}
/>
</div>
+329 -92
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Alert, Select, Spin, Tag, Tooltip, message } from 'antd';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Alert, App, Select, Spin, Tag, Tooltip } from 'antd';
import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
@@ -10,6 +10,8 @@ import type { AdRevenueReport, DashboardOverview } from '@/lib/types';
type PeriodKey = 'yesterday' | '7d' | '30d';
type TrendSeriesKey = 'dau' | 'newUsers' | 'comparisons';
type DashboardTrendPoint = DashboardOverview['period']['trend'][number];
type DeltaTone = 'muted' | 'up' | 'down' | 'flat';
type DeltaInfo = { value: ReactNode; tone: DeltaTone };
const PERIODS: { key: PeriodKey; label: string; days: number }[] = [
{ key: 'yesterday', label: '昨日', days: 1 },
@@ -35,6 +37,72 @@ const fmtYuan = (yuan: number | null | undefined) => (yuan == null ? '--' : `¥$
const fmtMsAsSeconds = (ms: number | null | undefined) => (ms == null ? '--' : (ms / 1000).toFixed(1));
const pct = (v: number | null | undefined) => (v == null ? '--' : `${(v * 100).toFixed(1)}%`);
const compact = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v));
const fmtCoinAmount = (v: number | null | undefined) => {
if (v == null) return '--';
if (Math.abs(v) >= 10000) return `${(v / 10000).toFixed(2).replace(/\.?0+$/, '')}`;
return numberFmt.format(v);
};
const CMP_LABEL = 'vs 上周期';
function withCompare(main: string) {
return (
<>
{main}
<span className="cmp">{CMP_LABEL}</span>
</>
);
}
function percentDelta(current: number | null | undefined, previous: number | null | undefined): DeltaInfo {
if (current == null || previous == null) return { value: '暂无上期', tone: 'flat' };
if (previous === 0) {
if (current === 0) return { value: withCompare('持平'), tone: 'flat' };
return { value: '上期为 0', tone: current > 0 ? 'up' : 'down' };
}
const diff = (current - previous) / Math.abs(previous);
if (Math.abs(diff) < 0.0005) return { value: withCompare('持平'), tone: 'flat' };
const arrow = diff > 0 ? '▲' : '▼';
return {
value: withCompare(`${arrow} ${(Math.abs(diff) * 100).toFixed(1)}%`),
tone: diff > 0 ? 'up' : 'down',
};
}
function pointDelta(current: number | null | undefined, previous: number | null | undefined): DeltaInfo {
if (current == null || previous == null) return { value: '暂无上期', tone: 'flat' };
const diff = (current - previous) * 100;
if (Math.abs(diff) < 0.05) return { value: withCompare('持平'), tone: 'flat' };
const arrow = diff > 0 ? '▲' : '▼';
return {
value: withCompare(`${arrow} ${Math.abs(diff).toFixed(1)}pt`),
tone: diff > 0 ? 'up' : 'down',
};
}
function durationDelta(currentMs: number | null | undefined, previousMs: number | null | undefined): DeltaInfo {
if (currentMs == null || previousMs == null) return { value: '暂无上期', tone: 'flat' };
const diffSeconds = (currentMs - previousMs) / 1000;
if (Math.abs(diffSeconds) < 0.05) return { value: withCompare('持平'), tone: 'flat' };
return diffSeconds < 0
? { value: `${Math.abs(diffSeconds).toFixed(1)}s 更快`, tone: 'up' }
: { value: `${Math.abs(diffSeconds).toFixed(1)}s 更慢`, tone: 'down' };
}
function moneyDeltaCents(current: number | null | undefined, previous: number | null | undefined): DeltaInfo {
if (current == null || previous == null) return { value: '暂无上期', tone: 'flat' };
const diff = current - previous;
if (Math.abs(diff) < 0.5) return { value: withCompare('持平'), tone: 'flat' };
const arrow = diff > 0 ? '▲' : '▼';
return {
value: withCompare(`${arrow} ${fmtCents(Math.abs(diff))}`),
tone: diff > 0 ? 'up' : 'down',
};
}
function ratioBadge(value: number | null | undefined, total: number | null | undefined): DeltaInfo {
if (value == null || total == null || total <= 0) return { value: '占 --', tone: 'flat' };
return { value: `${((value / total) * 100).toFixed(1)}%`, tone: 'flat' };
}
function periodRange(period: PeriodKey) {
const p = PERIODS.find((it) => it.key === period) ?? PERIODS[0];
@@ -43,6 +111,12 @@ function periodRange(period: PeriodKey) {
return { start, end, label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}`, days: p.days };
}
function previousPeriodRange(range: ReturnType<typeof periodRange>) {
const end = range.start.subtract(1, 'day');
const start = end.subtract(range.days - 1, 'day');
return { start, end, label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}` };
}
function retentionTitle(period: PeriodKey) {
if (period === '7d') return '近 7 日新增留存';
if (period === '30d') return '近 30 日新增留存';
@@ -92,6 +166,16 @@ function sceneAdEcpm(summary: ReturnType<typeof adSceneSummary>) {
return fmtYuan((summary.revenue_yuan / summary.impressions) * 1000);
}
function cpsCommissionCents(data: DashboardOverview | null | undefined) {
if (!data?.cps.available) return null;
return (data.cps.meituan_commission_cents ?? 0) + (data.cps.jd_commission_cents ?? 0);
}
function revenueYuan(ad: AdRevenueReport | null | undefined, cpsCents: number | null | undefined) {
if (ad?.total_revenue_yuan == null && cpsCents == null) return null;
return (ad?.total_revenue_yuan ?? 0) + ((cpsCents ?? 0) / 100);
}
function niceMax(v: number) {
if (v <= 5) return 5;
if (v <= 10) return 10;
@@ -99,7 +183,7 @@ function niceMax(v: number) {
return Math.ceil(v / base) * base;
}
function StatusText({ children, tone = 'muted' }: { children: React.ReactNode; tone?: 'muted' | 'ok' | 'warn' }) {
function StatusText({ children, tone = 'muted' }: { children: ReactNode; tone?: 'muted' | 'ok' | 'warn' }) {
return <span className={`status status-${tone}`}>{children}</span>;
}
@@ -107,8 +191,8 @@ function DeltaBadge({
children,
tone = 'muted',
}: {
children: React.ReactNode;
tone?: 'muted' | 'up' | 'down';
children: ReactNode;
tone?: DeltaTone;
}) {
return <span className={`delta delta-${tone}`}>{children}</span>;
}
@@ -116,11 +200,15 @@ function DeltaBadge({
function MiniSpark({
color = '#2F6BFF',
down,
flat,
}: {
color?: string;
down?: boolean;
flat?: boolean;
}) {
const points = down
const points = flat
? '0,16 13,16 26,15 39,16 52,15 65,16 78,15'
: down
? '0,8 13,10 26,9 39,14 52,12 65,18 78,20'
: '0,24 13,22 26,20 39,15 52,13 65,8 78,5';
return (
@@ -149,9 +237,9 @@ function StatCard({
status?: string;
tone?: 'muted' | 'ok' | 'warn';
emphasis?: boolean;
spark?: 'up' | 'down';
delta?: string;
deltaTone?: 'muted' | 'up' | 'down';
spark?: 'up' | 'down' | 'flat';
delta?: ReactNode;
deltaTone?: DeltaTone;
}) {
return (
<div className={`stat-card${emphasis ? ' stat-card-emphasis' : ''}`}>
@@ -168,7 +256,13 @@ function StatCard({
{value}
{unit && value !== '--' && <span>{unit}</span>}
</div>
{spark && <MiniSpark color={spark === 'down' ? '#EF4444' : '#2F6BFF'} down={spark === 'down'} />}
{spark && (
<MiniSpark
color={spark === 'down' ? '#EF4444' : '#2F6BFF'}
down={spark === 'down'}
flat={spark === 'flat'}
/>
)}
</div>
{delta ? <DeltaBadge tone={deltaTone}>{delta}</DeltaBadge> : status && <StatusText tone={tone}>{status}</StatusText>}
</div>
@@ -181,18 +275,20 @@ function RevenueCard({
meta,
accent,
delta,
deltaTone = 'up',
}: {
title: string;
value: string;
meta?: { label: string; value: string }[];
accent?: 'blue' | 'green' | 'yellow' | 'red';
delta?: string;
accent?: 'blue' | 'green' | 'yellow' | 'red' | 'jd';
delta?: ReactNode;
deltaTone?: DeltaTone;
}) {
return (
<div className={`revenue-card ${accent ? `rev-${accent}` : ''}`}>
<div className="revenue-top">
<div className="revenue-tag">{title}</div>
{delta && <DeltaBadge tone="up">{delta}</DeltaBadge>}
{delta && <DeltaBadge tone={deltaTone}>{delta}</DeltaBadge>}
</div>
<div className="revenue-value">{value}</div>
{meta && meta.length > 0 && (
@@ -349,9 +445,12 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
}
export default function DashboardPage() {
const { message } = App.useApp();
const [period, setPeriod] = useState<PeriodKey>('yesterday');
const [data, setData] = useState<DashboardOverview | null>(null);
const [previousData, setPreviousData] = useState<DashboardOverview | null>(null);
const [adReport, setAdReport] = useState<AdRevenueReport | null>(null);
const [previousAdReport, setPreviousAdReport] = useState<AdRevenueReport | null>(null);
const [loading, setLoading] = useState(true);
const [adLoading, setAdLoading] = useState(false);
const [overviewError, setOverviewError] = useState<string | null>(null);
@@ -361,7 +460,9 @@ export default function DashboardPage() {
const [cpsSyncing, setCpsSyncing] = useState(false);
const range = useMemo(() => periodRange(period), [period]);
const previousRange = useMemo(() => previousPeriodRange(range), [range]);
const periodData = data?.period ?? null;
const previousPeriodData = previousData?.period ?? null;
const adAvgEcpm = avgEcpm(adReport);
const rewardVideoAdSummary = adTypeSummary(adReport, 'reward_video');
const couponAdSummary = adSceneSummary(adReport, 'coupon');
@@ -381,44 +482,84 @@ export default function DashboardPage() {
const regularTaskCoinTotal =
periodData?.coins.regular_task_coin_total ??
((signinCoinTotal ?? 0) + (signinBoostCoinTotal ?? 0) + (taskCoinTotal ?? 0));
const cpsCommissionCents = data?.cps.meituan_commission_cents;
const cpsAvailable = data?.cps.available === true;
const totalRevenueYuan =
adReport?.total_revenue_yuan == null && (!cpsAvailable || cpsCommissionCents == null)
? null
: (adReport?.total_revenue_yuan ?? 0) + (cpsAvailable ? (cpsCommissionCents ?? 0) / 100 : 0);
const meituanCpsCommissionCents = data?.cps.meituan_commission_cents;
const jdCpsCommissionCents = data?.cps.jd_commission_cents;
const totalCpsCommissionCents = cpsCommissionCents(data);
const previousTotalCpsCommissionCents = cpsCommissionCents(previousData);
const totalRevenueYuan = revenueYuan(adReport, totalCpsCommissionCents);
const previousTotalRevenueYuan = revenueYuan(previousAdReport, previousTotalCpsCommissionCents);
const yesterdayTrendPoint = periodData?.trend?.[periodData.trend.length - 1];
const yesterdayActiveUsers = yesterdayTrendPoint?.active_users ?? null;
const arpuYuan =
totalRevenueYuan == null || yesterdayActiveUsers == null || yesterdayActiveUsers <= 0
? null
: totalRevenueYuan / yesterdayActiveUsers;
const cpsRevenueText = cpsAvailable ? fmtCents(cpsCommissionCents) : '--';
const cpsRevenueText = cpsAvailable ? fmtCents(totalCpsCommissionCents) : '--';
const meituanCpsRevenueText = cpsAvailable ? fmtCents(meituanCpsCommissionCents) : '--';
const jdCpsRevenueText = cpsAvailable ? fmtCents(jdCpsCommissionCents) : '--';
const cpsHitRate = !cpsAvailable || data?.cps.meituan_hit_rate == null ? '--' : pct(data.cps.meituan_hit_rate);
const retentionValue =
periodData?.users.retention_rate == null
? '--'
: (periodData.users.retention_rate * 100).toFixed(1);
const activeUserDelta = percentDelta(periodData?.users.active, previousPeriodData?.users.active);
const newUserDelta = percentDelta(periodData?.users.new, previousPeriodData?.users.new);
const retentionDelta = pointDelta(periodData?.users.retention_rate, previousPeriodData?.users.retention_rate);
const retentionSpark: 'up' | 'down' | 'flat' | undefined =
periodData?.users.retention_rate == null
? undefined
: retentionDelta.tone === 'down'
? 'down'
: retentionDelta.tone === 'up' || periodData.users.retention_rate >= 1
? 'up'
: 'flat';
const comparisonTotalDelta = percentDelta(periodData?.comparison.total, previousPeriodData?.comparison.total);
const comparisonSuccessDelta = percentDelta(periodData?.comparison.success, previousPeriodData?.comparison.success);
const comparisonOrderedDelta = percentDelta(periodData?.comparison.ordered, previousPeriodData?.comparison.ordered);
const durationDeltaInfo = durationDelta(
periodData?.comparison.average_duration_ms,
previousPeriodData?.comparison.average_duration_ms,
);
const averageSavedDelta = moneyDeltaCents(
periodData?.comparison.average_saved_cents,
previousPeriodData?.comparison.average_saved_cents,
);
const totalRevenueDelta = percentDelta(totalRevenueYuan, previousTotalRevenueYuan);
const adRevenueDelta = percentDelta(adReport?.total_revenue_yuan, previousAdReport?.total_revenue_yuan);
const cpsRevenueDelta = percentDelta(totalCpsCommissionCents, previousTotalCpsCommissionCents);
const grantedCoinDelta = percentDelta(periodData?.coins.granted_total, previousPeriodData?.coins.granted_total);
const couponCoinRatio = ratioBadge(couponRewardCoinTotal, periodData?.coins.granted_total);
const comparisonCoinRatio = ratioBadge(comparisonRewardCoinTotal, periodData?.coins.granted_total);
const rewardVideoCoinRatio = ratioBadge(periodData?.coins.reward_video_coin_total, periodData?.coins.granted_total);
const regularTaskCoinRatio = ratioBadge(regularTaskCoinTotal, periodData?.coins.granted_total);
useEffect(() => {
let alive = true;
setLoading(true);
setOverviewError(null);
api
.get<DashboardOverview>('/admin/api/stats/overview', {
params: {
date_from: range.start.format('YYYY-MM-DD'),
date_to: range.end.format('YYYY-MM-DD'),
},
})
.then((r) => {
const currentParams = {
date_from: range.start.format('YYYY-MM-DD'),
date_to: range.end.format('YYYY-MM-DD'),
};
const previousParams = {
date_from: previousRange.start.format('YYYY-MM-DD'),
date_to: previousRange.end.format('YYYY-MM-DD'),
};
Promise.allSettled([
api.get<DashboardOverview>('/admin/api/stats/overview', { params: currentParams }),
api.get<DashboardOverview>('/admin/api/stats/overview', { params: previousParams }),
])
.then(([current, previous]) => {
if (!alive) return;
setData(r.data);
setUpdatedAt(dayjs().format('YYYY-MM-DD HH:mm'));
})
.catch(() => {
if (!alive) return;
setOverviewError('总览接口加载失败');
if (current.status === 'fulfilled') {
setData(current.value.data);
setUpdatedAt(dayjs().format('YYYY-MM-DD HH:mm'));
} else {
setData(null);
setOverviewError('总览接口加载失败');
}
setPreviousData(previous.status === 'fulfilled' ? previous.value.data : null);
})
.finally(() => {
if (alive) setLoading(false);
@@ -426,29 +567,37 @@ export default function DashboardPage() {
return () => {
alive = false;
};
}, [range.start, range.end, refreshKey]);
}, [range.start, range.end, previousRange.start, previousRange.end, refreshKey]);
useEffect(() => {
let alive = true;
setAdLoading(true);
setAdError(null);
api
.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: {
date_from: range.start.format('YYYY-MM-DD'),
date_to: range.end.format('YYYY-MM-DD'),
granularity: 'day',
limit: 1000,
},
})
.then((r) => {
const currentParams = {
date_from: range.start.format('YYYY-MM-DD'),
date_to: range.end.format('YYYY-MM-DD'),
granularity: 'day',
limit: 1000,
};
const previousParams = {
date_from: previousRange.start.format('YYYY-MM-DD'),
date_to: previousRange.end.format('YYYY-MM-DD'),
granularity: 'day',
limit: 1000,
};
Promise.allSettled([
api.get<AdRevenueReport>('/admin/api/ad-revenue-report', { params: currentParams }),
api.get<AdRevenueReport>('/admin/api/ad-revenue-report', { params: previousParams }),
])
.then(([current, previous]) => {
if (!alive) return;
setAdReport(r.data);
})
.catch(() => {
if (!alive) return;
setAdReport(null);
setAdError('广告收益接口加载失败');
if (current.status === 'fulfilled') {
setAdReport(current.value.data);
} else {
setAdReport(null);
setAdError('广告收益接口加载失败');
}
setPreviousAdReport(previous.status === 'fulfilled' ? previous.value.data : null);
})
.finally(() => {
if (alive) setAdLoading(false);
@@ -456,7 +605,7 @@ export default function DashboardPage() {
return () => {
alive = false;
};
}, [range.start, range.end]);
}, [range.start, range.end, previousRange.start, previousRange.end]);
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
@@ -464,7 +613,7 @@ export default function DashboardPage() {
return <Alert type="error" showIcon message={overviewError ?? '数据大盘加载失败'} />;
}
const syncMeituanCps = async () => {
const syncCpsOrders = async () => {
setCpsSyncing(true);
try {
const resp = await api.post<{
@@ -476,12 +625,13 @@ export default function DashboardPage() {
params: {
date_from: range.start.format('YYYY-MM-DD'),
date_to: range.end.format('YYYY-MM-DD'),
platform: 'all',
},
});
message.success(`已同步 ${resp.data.fetched}`);
setRefreshKey((v) => v + 1);
} catch {
message.error('美团订单刷新失败');
message.error('CPS 订单刷新失败');
} finally {
setCpsSyncing(false);
}
@@ -527,20 +677,20 @@ export default function DashboardPage() {
<h3></h3>
<span></span>
</div>
<div className="grid g5">
<StatCard title="总用户" value={fmtInt(data.users.total)} status="累计用户数" tone="ok" spark="up" />
<div className="grid g4">
<StatCard
title="活跃用户"
value={fmtInt(periodData?.users.active)}
status="本期活跃去重"
tone="ok"
hint="当前按本期内进入 App、完成比价记录、开始领券流程任一满足去重;未完成上报的比价开始事件待后续补埋点。"
delta={activeUserDelta.value}
deltaTone={activeUserDelta.tone}
hint="当前按本期内登录、开始比价、开始领券任一满足去重;比价按 real_compare_start,领券按 real_coupon_start/claim_started。"
spark="up"
/>
<StatCard
title="新增用户"
value={fmtInt(periodData?.users.new)}
status="本期新用户"
delta={newUserDelta.value}
deltaTone={newUserDelta.tone}
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
spark="up"
/>
@@ -548,16 +698,16 @@ export default function DashboardPage() {
title={retentionTitle(period)}
value={retentionValue}
unit="%"
status={`${fmtInt(periodData?.users.retained_new_users)} / ${fmtInt(periodData?.users.new)} 新用户`}
tone="warn"
delta={retentionDelta.value}
deltaTone={retentionDelta.tone}
hint={periodData?.users.retention_note ?? '暂无留存数据'}
spark="down"
spark={retentionSpark}
/>
<StatCard
title="比价次数"
value={fmtInt(periodData?.comparison.total)}
status="本期比价次数"
tone="muted"
delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone}
spark="up"
/>
</div>
@@ -571,23 +721,43 @@ export default function DashboardPage() {
<span></span>
</div>
<div className="grid g5">
<StatCard title="发起比价" value={fmtInt(periodData?.comparison.total)} unit="次" status="本期发起次数" />
<StatCard title="比价成功" value={fmtInt(periodData?.comparison.success)} unit="次" status={pct(compareSuccessRate)} tone="ok" />
<StatCard
title="发起比价"
value={fmtInt(periodData?.comparison.total)}
unit="次"
delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone}
/>
<StatCard
title="比价成功"
value={fmtInt(periodData?.comparison.success)}
unit="次"
delta={comparisonSuccessDelta.value}
deltaTone={comparisonSuccessDelta.tone}
hint={`当前成功率 ${pct(compareSuccessRate)}`}
/>
<StatCard
title="成功下单"
value={fmtInt(periodData?.comparison.ordered)}
unit="单"
status="已下单记录"
delta={comparisonOrderedDelta.value}
deltaTone={comparisonOrderedDelta.tone}
hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。"
/>
<StatCard
title="平均耗时"
value={fmtMsAsSeconds(averageDurationMs)}
unit="秒"
status={averageDurationMs == null ? '暂无耗时数据' : '本期平均耗时'}
delta={durationDeltaInfo.value}
deltaTone={durationDeltaInfo.tone}
hint="按比价记录页同一口径:取所选日期范围内已上报耗时的比价记录,求平均后换算为秒。"
/>
<StatCard title="平均节省金额" value={fmtCents(periodData?.comparison.average_saved_cents)} status="有节省的成功比价" />
<StatCard
title="平均节省金额"
value={fmtCents(periodData?.comparison.average_saved_cents)}
delta={averageSavedDelta.value}
deltaTone={averageSavedDelta.tone}
/>
</div>
</section>
@@ -597,7 +767,7 @@ export default function DashboardPage() {
<h3></h3>
<span>
{cpsAvailable
? (adError ? '广告收益接口当前后端不可用,美团 CPS 已接入' : '广告收益与美团 CPS 已接入')
? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入')
: (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
</span>
</div>
@@ -607,6 +777,8 @@ export default function DashboardPage() {
title="总收入"
value={adLoading ? '--' : fmtYuan(totalRevenueYuan)}
accent="blue"
delta={totalRevenueDelta.value}
deltaTone={totalRevenueDelta.tone}
meta={[
{ label: '广告收入', value: adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan) },
{ label: 'CPS 收入', value: cpsRevenueText },
@@ -615,38 +787,58 @@ export default function DashboardPage() {
<RevenueCard
title="广告收入"
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
meta={[{ label: '展示数', value: adLoading ? '--' : fmtInt(adReport?.total_impressions) }]}
delta={adRevenueDelta.value}
deltaTone={adRevenueDelta.tone}
meta={[
{
label: '占总收入',
value:
totalRevenueYuan && totalRevenueYuan > 0 && adReport?.total_revenue_yuan != null
? `${((adReport.total_revenue_yuan / totalRevenueYuan) * 100).toFixed(1)}%`
: '--',
},
]}
/>
<RevenueCard
title="CPS 收入"
value={cpsRevenueText}
meta={[{ label: '来源', value: cpsAvailable ? '美团已接,淘宝/京东暂空' : '订单/佣金待接' }]}
delta={cpsRevenueDelta.value}
deltaTone={cpsRevenueDelta.tone}
meta={[
{
label: '占总收入',
value:
totalRevenueYuan && totalRevenueYuan > 0 && totalCpsCommissionCents != null
? `${(((totalCpsCommissionCents / 100) / totalRevenueYuan) * 100).toFixed(1)}%`
: '--',
},
]}
/>
<RevenueCard
title="ARPU"
value={adLoading ? '--' : fmtYuan(arpuYuan)}
meta={[
{ label: '口径', value: '收入 / 活跃用户' },
{ label: 'ARPPU', value: '待确认付费用户口径' },
{ label: '口径', value: '收入 / 昨日活跃用户' },
{ label: '分母', value: yesterdayActiveUsers == null ? '--' : `${fmtInt(yesterdayActiveUsers)}` },
]}
/>
</div>
<div className="sub-label cps-sub-label">
<span>CPS </span>
<button type="button" onClick={syncMeituanCps} disabled={cpsSyncing}>
{cpsSyncing ? '刷新中' : '刷新美团订单'}
<button type="button" onClick={syncCpsOrders} disabled={cpsSyncing}>
{cpsSyncing ? '刷新中' : '刷新 CPS 订单'}
</button>
</div>
<div className="grid g4">
<RevenueCard
title="总 CPS 收益"
value={cpsRevenueText}
meta={[{ label: '来源', value: cpsAvailable ? '美团订单佣金,淘宝/京东暂空' : '订单/佣金待接' }]}
meta={[{ label: '来源', value: cpsAvailable ? '美团+京东订单佣金,淘宝暂未接入' : '订单/佣金待接' }]}
/>
<RevenueCard
title="美团 CPS"
value={cpsRevenueText}
value={meituanCpsRevenueText}
meta={[
{ label: '订单量', value: cpsAvailable ? fmtInt(data?.cps.meituan_order_count) : '待订单数据' },
{
@@ -658,8 +850,16 @@ export default function DashboardPage() {
]}
accent="yellow"
/>
<RevenueCard title="淘宝 CPS" value="--" meta={[{ label: '状态', value: '后续接入后填写' }]} accent="red" />
<RevenueCard title="京东 CPS" value="--" meta={[{ label: '状态', value: '后续接入后填写' }]} accent="red" />
<RevenueCard title="淘宝 CPS" value="--" meta={[{ label: '状态', value: '暂未接入' }]} accent="red" />
<RevenueCard
title="京东 CPS"
value={jdCpsRevenueText}
meta={[
{ label: '有效订单', value: cpsAvailable ? fmtInt(data?.cps.jd_order_count) : '待订单数据' },
{ label: '无效订单', value: cpsAvailable ? fmtInt(data?.cps.jd_invalid_count ?? 0) : '待订单数据' },
]}
accent="jd"
/>
</div>
<div className="sub-label">广</div>
@@ -705,32 +905,58 @@ export default function DashboardPage() {
<span className="bar" />
<h3></h3>
<Tag color="warning"></Tag>
<span></span>
<span> = = </span>
</div>
<div className="grid g6">
<StatCard title="本期发放金币" value={fmtInt(periodData?.coins.granted_total)} status="本期发放合计" />
<StatCard
title="本期发放金币"
value={fmtInt(periodData?.coins.granted_total)}
delta={grantedCoinDelta.value}
deltaTone={grantedCoinDelta.tone}
emphasis
/>
<StatCard
title="领券奖励金币"
value={fmtInt(couponRewardCoinTotal)}
status="领券相关发放"
delta={couponCoinRatio.value}
deltaTone={couponCoinRatio.tone}
/>
<StatCard
title="比价奖励金币"
value={fmtInt(comparisonRewardCoinTotal)}
status="比价相关发放"
delta={comparisonCoinRatio.value}
deltaTone={comparisonCoinRatio.tone}
/>
<StatCard
title="激励视频金币"
value={fmtInt(periodData?.coins.reward_video_coin_total)}
delta={rewardVideoCoinRatio.value}
deltaTone={rewardVideoCoinRatio.tone}
hint="激励视频金币已按产品口径计入领券奖励金币,这里单独展示来源占比。"
/>
<StatCard title="激励视频金币" value="--" status="并入领券奖励" />
<StatCard
title="常规任务金币"
value={fmtInt(regularTaskCoinTotal)}
status="任务等发放"
delta={regularTaskCoinRatio.value}
deltaTone={regularTaskCoinRatio.tone}
/>
<StatCard
title="本期提现金额"
value={fmtCents(periodData?.cash.withdraw_success_cents)}
delta="实付现金"
deltaTone="flat"
/>
<StatCard title="本期提现金额" value={fmtCents(periodData?.cash.withdraw_success_cents)} status="成功提现" />
</div>
<div className="risk-note">
{fmtInt(periodData?.coins.granted_total)} {fmtCents(periodData?.cash.withdraw_success_cents)}
{fmtInt(data.coins.granted_total)} {fmtCents(data.cash.withdraw_success_cents)}
广广
<div>
4 <b>{fmtCoinAmount(periodData?.coins.granted_total)}</b>
<b>{fmtYuan((periodData?.coins.granted_total ?? 0) / 1000)}</b>
<b>{fmtCents(periodData?.cash.withdraw_success_cents)}</b>
<b>{fmtCoinAmount(data.coins.granted_total)}</b>
<b>{fmtCents(data.cash.withdraw_success_cents)}</b>/
</div>
</div>
</section>
@@ -772,7 +998,7 @@ export default function DashboardPage() {
--coin: #ffb300;
--p-mt-waimai: #ffc300;
--p-taobao: #ff4400;
--p-jd: #e1251b;
--p-jd: #b91c1c;
--radius: 10px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.04);
color: var(--ink);
@@ -967,10 +1193,17 @@ export default function DashboardPage() {
font-weight: 600;
}
.status-muted,
.delta-muted {
.delta-muted,
.delta-flat {
color: var(--ink-3);
background: #f1f3f7;
}
.delta .cmp {
margin-left: 3px;
color: var(--ink-3);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
font-weight: 400;
}
.status-ok,
.delta-up {
color: var(--up);
@@ -1144,6 +1377,10 @@ export default function DashboardPage() {
background: var(--p-taobao);
color: #fff;
}
.rev-jd .revenue-tag {
background: var(--p-jd);
color: #fff;
}
.revenue-value {
color: var(--ink);
font-family: "DIN Alternate", "Helvetica Neue", sans-serif;
@@ -19,7 +19,7 @@ import {
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { formatWallTime } from '@/lib/format';
import { mediaUrl } from '@/lib/media';
import type { CursorPage, Feedback } from '@/lib/types';
@@ -153,7 +153,7 @@ export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }
{/* 本条反馈 */}
<div>
<Text type="secondary">
{feedback.user_id} · {formatUtcTime(feedback.created_at)}
{feedback.user_id} · {formatWallTime(feedback.created_at)}
</Text>
<Paragraph style={{ whiteSpace: 'pre-wrap', marginTop: 8, marginBottom: 8 }}>
{feedback.content || '-'}
@@ -185,7 +185,7 @@ export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }
<div>
:{statusTag(feedback.status)}
{feedback.reviewed_at ? (
<Text type="secondary"> · {formatUtcTime(feedback.reviewed_at)}</Text>
<Text type="secondary"> · {formatWallTime(feedback.reviewed_at)}</Text>
) : null}
</div>
{feedback.status === 'adopted' && (
@@ -292,7 +292,7 @@ export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">
#{h.id} · {formatUtcTime(h.created_at)}
#{h.id} · {formatWallTime(h.created_at)}
</Text>
{statusTag(h.status)}
</div>
+162 -42
View File
@@ -1,26 +1,29 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
Button,
Card,
DatePicker,
Image,
Input,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { formatWallTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { Feedback } from '@/lib/types';
import FeedbackQrConfig from './FeedbackQrConfig';
import type { Feedback, FeedbackSummary } from '@/lib/types';
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
@@ -41,6 +44,75 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
rejected: { label: '未采纳', color: 'red' },
};
const statusTag = (s: string) => {
const m = STATUS_META[s] ?? { label: s, color: 'default' };
return <Tag color={m.color}>{m.label}</Tag>;
};
// 审核结果展示(采纳=金币+批注 / 未采纳=原因 / 待审核=-),主表与「该用户全部反馈」抽屉共用
const renderReview = (f: Feedback) => {
if (f.status === 'adopted') {
return (
<Space direction="vertical" size={0}>
<Tag color="gold">+{f.reward_coins ?? 0} </Tag>
{f.review_note ? (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{f.review_note}
</Text>
) : null}
</Space>
);
}
if (f.status === 'rejected') {
return (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
:{f.reject_reason || '-'}
</Text>
);
}
return <Text type="secondary">-</Text>;
};
// 机型 / OS版本(提交反馈时的端环境;改版前历史反馈无 → -)
const renderDeviceOs = (f: Feedback) => {
const os = [f.android_version ? `Android ${f.android_version}` : null, f.rom_name]
.filter(Boolean)
.join(' · ');
if (!f.device_model && !os) return <Text type="secondary">-</Text>;
return (
<Space direction="vertical" size={0}>
<Text>{f.device_model || '-'}</Text>
{os ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{os}
</Text>
) : null}
</Space>
);
};
// 点手机号弹出的「该用户全部反馈」抽屉列(精简版:不含操作/手机号,避免抽屉里再套抽屉)
const RECORD_COLUMNS: ColumnsType<Feedback> = [
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
{ title: '状态', dataIndex: 'status', width: 80, render: statusTag },
{
title: '内容',
dataIndex: 'content',
render: (v: string) =>
v ? (
<Paragraph
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
>
{v}
</Paragraph>
) : (
<Text type="secondary">-</Text>
),
},
{ title: '审核结果', key: 'review', width: 160, render: (_: unknown, f: Feedback) => renderReview(f) },
];
export default function FeedbacksPage() {
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
@@ -58,6 +130,19 @@ export default function FeedbacksPage() {
const canReview = canDo(['operator']);
const [summary, setSummary] = useState<FeedbackSummary | null>(null);
const loadSummary = async () => {
try {
const { data } = await api.get<FeedbackSummary>('/admin/api/feedbacks/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
};
useEffect(() => {
loadSummary();
}, []);
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -66,6 +151,10 @@ export default function FeedbacksPage() {
setDrawerOpen(true);
};
// 点手机号:看该用户全部反馈
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (f: Feedback) => setRecordsUser({ userId: f.user_id, phone: f.phone });
const search = () =>
setApplied({
status,
@@ -104,8 +193,41 @@ export default function FeedbacksPage() {
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const columns: ColumnsType<Feedback> = [
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '用户', dataIndex: 'user_id', width: 80 },
{
title: '用户ID',
dataIndex: 'user_id',
width: 80,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (_: unknown, f: Feedback) =>
f.phone ? (
<a onClick={() => openUserRecords(f)}>{f.phone}</a>
) : (
<Text type="secondary"></Text>
),
},
{
title: '用户昵称',
dataIndex: 'nickname',
width: 120,
render: (v: string | null) => v || <Text type="secondary"></Text>,
},
{
title: '提交版本号',
dataIndex: 'app_version',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '机型 / OS版本',
key: 'device',
width: 150,
render: (_: unknown, f: Feedback) => renderDeviceOs(f),
},
{
title: '内容',
dataIndex: 'content',
@@ -145,49 +267,20 @@ export default function FeedbacksPage() {
<Text type="secondary"></Text>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => {
const m = STATUS_META[s] ?? { label: s, color: 'default' };
return <Tag color={m.color}>{m.label}</Tag>;
},
},
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{
title: '审核结果',
key: 'review',
width: 170,
render: (_: unknown, f: Feedback) => {
if (f.status === 'adopted') {
return (
<Space direction="vertical" size={0}>
<Tag color="gold">+{f.reward_coins ?? 0} </Tag>
{f.review_note ? (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{f.review_note}
</Text>
) : null}
</Space>
);
}
if (f.status === 'rejected') {
return (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
:{f.reject_reason || '-'}
</Text>
);
}
return <Text type="secondary">-</Text>;
},
render: (_: unknown, f: Feedback) => renderReview(f),
},
{
title: '时间',
title: '提交时间',
dataIndex: 'created_at',
width: 170,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
render: (v: string) => formatWallTime(v),
},
{
title: '操作',
@@ -204,8 +297,21 @@ export default function FeedbacksPage() {
return (
<div>
<h2></h2>
<FeedbackQrConfig />
<h2></h2>
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已采纳" value={summary?.adopted ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="未采纳" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="合计" value={summary?.total ?? 0} />
</Card>
</div>
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="状态"
@@ -261,13 +367,27 @@ export default function FeedbacksPage() {
onChange: onPageChange,
}}
onChange={onTableChange}
scroll={{ x: 1610 }}
/>
<FeedbackHandleDrawer
feedback={drawerFb}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onDone={reload}
onDone={() => { reload(); loadSummary(); }}
/>
<UserRecordsDrawer<Feedback>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/feedbacks"
columns={RECORD_COLUMNS}
recordLabel="反馈"
countLabel="提交总计"
rewardLabel="提交奖励总计"
rewardOf={(f) => (f.status === 'adopted' ? (f.reward_coins ?? 0) : 0)}
/>
</div>
);
+2 -2
View File
@@ -32,9 +32,9 @@ const MENU = [
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
{ key: '/device-liveness', icon: <HeartOutlined />, 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: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '用户反馈' },
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
+201 -67
View File
@@ -16,6 +16,7 @@ import {
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api, errMsg } from '@/lib/api';
@@ -23,6 +24,7 @@ import { canDo } from '@/lib/auth';
import { mediaUrl } from '@/lib/media';
import { usePagedList } from '@/lib/usePagedList';
import type { PriceReport, PriceReportSummary } from '@/lib/types';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text } = Typography;
@@ -55,19 +57,112 @@ function statusTag(status: string) {
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
}
type SortField = 'id' | 'created_at';
// 门店/菜品、原价→上报价、审核结果:主表与「该用户全部上报」抽屉共用
const renderStore = (r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text>{r.store_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.dish_summary || '-'}
</Text>
</Space>
);
const renderPrice = (r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
</Text>
<Text strong style={{ color: '#fa541c' }}>
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
</Text>
</Space>
);
// 只读审核结果(「审核结果」列 + 抽屉共用,不带操作按钮;待审核在「状态」列已显示,这里给 -)
const renderResult = (r: PriceReport) => {
if (r.status === 'approved') return <Text type="success"> {r.reward_coins ?? 0} </Text>;
if (r.status === 'rejected') return <Text type="secondary">: {r.reject_reason || '-'}</Text>;
return <Text type="secondary">-</Text>;
};
// Trace:与「比价记录」页一致 —— 有 trace_url(本地比价=localhost debug viewer,线上=公网)
// 就给可点的「trace」链接,否则 -。
const traceLink = (r: PriceReport) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer">
trace
</a>
) : (
<Text type="secondary">-</Text>
);
// 机型 / OS版本(取自关联比价记录;无关联记录时为 -)
const renderDeviceOs = (r: PriceReport) => {
const os = [r.android_version ? `Android ${r.android_version}` : null, r.rom_name]
.filter(Boolean)
.join(' · ');
if (!r.device_model && !os) return <Text type="secondary">-</Text>;
return (
<Space direction="vertical" size={0}>
<Text>{r.device_model || '-'}</Text>
{os ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{os}
</Text>
) : null}
</Space>
);
};
// 点手机号弹出的「该用户全部低价上报」抽屉列(精简版:不含操作/手机号)
const RECORD_COLUMNS: ColumnsType<PriceReport> = [
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => dt(v) },
{ title: '门店 / 菜品', key: 'store', width: 180, render: (_: unknown, r: PriceReport) => renderStore(r) },
{ title: '原价 → 上报价', key: 'price', width: 200, render: (_: unknown, r: PriceReport) => renderPrice(r) },
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{ title: 'Trace', key: 'trace', width: 80, render: (_: unknown, r: PriceReport) => traceLink(r) },
{ title: '结果', key: 'result', width: 150, render: (_: unknown, r: PriceReport) => renderResult(r) },
];
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);
const [rejectReason, setRejectReason] = useState('');
// 提交时间列服务端排序(默认按提交时间倒序,最新在前)
const [sortBy, setSortBy] = useState<SortField>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// 点手机号:看该用户全部低价上报
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (r: PriceReport) => setRecordsUser({ userId: r.user_id, phone: r.phone });
const filters: Record<string, unknown> = {};
const filters: Record<string, unknown> = { sort_by: sortBy, sort_order: sortOrder };
if (activeStatus !== 'all') filters.status = activeStatus;
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<PriceReport> | SorterResult<PriceReport>[],
) => {
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('created_at');
setSortOrder('desc');
}
};
const canReview = canDo(['operator']);
const loadSummary = async () => {
@@ -136,38 +231,51 @@ export default function PriceReportsPage() {
const columns: ColumnsType<PriceReport> = [
{
title: '用户',
title: '用户ID',
dataIndex: 'user_id',
width: 90,
width: 80,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (_: unknown, r: PriceReport) =>
r.phone ? (
<a onClick={() => openUserRecords(r)}>{r.phone}</a>
) : (
<Text type="secondary"></Text>
),
},
{
title: '用户昵称',
dataIndex: 'nickname',
width: 120,
render: (v: string | null) => v || <Text type="secondary"></Text>,
},
{
title: '提交版本号',
dataIndex: 'app_version',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '机型 / OS版本',
key: 'device',
width: 150,
render: (_: unknown, r: PriceReport) => renderDeviceOs(r),
},
{
title: '门店 / 菜品',
key: 'store',
width: 200,
render: (_: unknown, r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text>{r.store_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.dish_summary || '-'}
</Text>
</Space>
),
render: (_: unknown, r: PriceReport) => renderStore(r),
},
{
title: '原最低价 → 上报价',
key: 'price',
width: 220,
render: (_: unknown, r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
</Text>
<Text strong style={{ color: '#fa541c' }}>
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
</Text>
</Space>
),
render: (_: unknown, r: PriceReport) => renderPrice(r),
},
{
title: '截图证明',
@@ -193,52 +301,58 @@ export default function PriceReportsPage() {
),
},
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{
title: '审核结果',
key: 'review',
width: 160,
render: (_: unknown, r: PriceReport) => renderResult(r),
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 150,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => dt(v),
},
{
title: '操作 / 结果',
title: 'Trace',
key: 'trace',
width: 80,
render: (_: unknown, r: PriceReport) => traceLink(r),
},
{
title: '操作',
key: 'op',
fixed: 'right',
width: 200,
width: 150,
render: (_: unknown, r: PriceReport) => {
if (r.status === 'pending') {
if (!canReview) return <Text type="secondary"></Text>;
return (
<Space>
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => approve(r)}
>
</Button>
<Button
size="small"
type="link"
danger
icon={<CloseCircleOutlined />}
onClick={() => {
setRejecting(r);
setRejectReason('');
}}
>
</Button>
</Space>
);
}
if (r.status === 'approved') {
return <Text type="success"> {r.reward_coins ?? 0} </Text>;
}
if (r.status === 'rejected') {
return <Text type="secondary">: {r.reject_reason || '-'}</Text>;
}
return <Text type="secondary">-</Text>;
if (r.status !== 'pending') return <Text type="secondary">-</Text>;
if (!canReview) return <Text type="secondary"></Text>;
return (
<Space>
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => approve(r)}
>
</Button>
<Button
size="small"
type="link"
danger
icon={<CloseCircleOutlined />}
onClick={() => {
setRejecting(r);
setRejectReason('');
}}
>
</Button>
</Space>
);
},
},
];
@@ -247,7 +361,7 @@ export default function PriceReportsPage() {
<div>
<Space direction="vertical" style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Text type="secondary">
+ 1000 app
@@ -255,14 +369,20 @@ export default function PriceReportsPage() {
</Text>
</Space>
{summary && (
<Space size="large" style={{ marginBottom: 16 }}>
<Statistic title="待审核" value={summary.pending} valueStyle={{ color: '#faad14' }} />
<Statistic title="已通过" value={summary.approved} valueStyle={{ color: '#52c41a' }} />
<Statistic title="已拒绝" value={summary.rejected} />
<Statistic title="合计" value={summary.total} />
</Space>
)}
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已通过" value={summary?.approved ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已拒绝" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="合计" value={summary?.total ?? 0} />
</Card>
</div>
<Card>
<Tabs
@@ -275,6 +395,7 @@ export default function PriceReportsPage() {
columns={columns}
dataSource={items}
loading={loading}
onChange={onTableChange}
pagination={{
current: page,
pageSize,
@@ -283,7 +404,7 @@ export default function PriceReportsPage() {
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1100 }}
scroll={{ x: 1790 }}
/>
</Card>
@@ -320,6 +441,19 @@ export default function PriceReportsPage() {
</Space>
)}
</Modal>
<UserRecordsDrawer<PriceReport>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/price-reports"
columns={RECORD_COLUMNS}
recordLabel="低价上报"
countLabel="上报总计"
rewardLabel="上报奖励总计"
rewardOf={(r) => (r.status === 'approved' ? (r.reward_coins ?? 0) : 0)}
/>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
'use client';
// 点列表里的手机号 → 抽屉列出该用户「同类型」的全部记录(反馈页=全部反馈;低价审核页=全部上报)。
// 顶部汇总该用户的「提交/上报总计」+「奖励总计」;复用各自的 list 接口按 user_id 过滤(无需新后端接口)。
import { useEffect, useState } from 'react';
import { Drawer, Empty, Space, Spin, Statistic, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { CursorPage } from '@/lib/types';
const { Text } = Typography;
interface Props<T> {
open: boolean;
onClose: () => void;
userId: number | null;
phone: string | null;
endpoint: string; // 列表接口,按 user_id 过滤,如 /admin/api/feedbacks
columns: ColumnsType<T>;
recordLabel: string; // 「反馈」/「低价上报」,用于标题与空态文案
countLabel: string; // 顶部计数卡标题,如「提交总计」/「上报总计」
rewardLabel: string; // 顶部奖励卡标题,如「提交奖励总计」/「上报奖励总计」
rewardOf: (item: T) => number; // 单条已发奖励金币(未采纳/未通过返回 0)
}
export default function UserRecordsDrawer<T extends { id: number }>({
open,
onClose,
userId,
phone,
endpoint,
columns,
recordLabel,
countLabel,
rewardLabel,
rewardOf,
}: Props<T>) {
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || userId == null) return;
let alive = true;
setLoading(true);
api
.get<CursorPage<T>>(endpoint, {
params: { user_id: userId, limit: 100, sort_by: 'created_at', sort_order: 'desc' },
})
.then((r) => {
if (!alive) return;
setItems(r.data.items);
setTotal(r.data.total ?? r.data.items.length);
})
.catch(() => {
if (!alive) return;
setItems([]);
setTotal(0);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [open, userId, endpoint]);
// 奖励总计在已加载记录上累加(单用户记录数远小于 100 上限,够用)
const rewardTotal = items.reduce((sum, it) => sum + rewardOf(it), 0);
return (
<Drawer
title={
<span>
#{userId ?? '-'} {recordLabel}
{phone ? (
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>
{phone}
</Text>
) : null}
</span>
}
width={760}
open={open}
onClose={onClose}
destroyOnHidden
>
{loading ? (
<Spin style={{ display: 'block', margin: '40px auto' }} />
) : (
<>
<Space size={48} style={{ marginBottom: 20 }}>
<Statistic title={countLabel} value={total} />
<Statistic title={rewardLabel} value={rewardTotal} suffix="金币" />
</Space>
{items.length === 0 ? (
<Empty description={`该用户暂无${recordLabel}`} style={{ marginTop: 24 }} />
) : (
<Table<T>
rowKey="id"
size="small"
columns={columns}
dataSource={items}
pagination={false}
/>
)}
</>
)}
</Drawer>
);
}
+62 -28
View File
@@ -247,6 +247,22 @@ export interface Feedback {
reviewed_by_admin_id: number | null;
reviewed_at: string | null;
created_at: string;
// 提交端环境快照:提交版本号 / 机型OS版本;改版前的历史反馈为 null
app_version: string | null;
device_model: string | null;
rom_name: string | null;
android_version: string | null;
// 联表瞬态:展示完整手机号(点手机号查该用户全部反馈)
phone: string | null;
nickname: string | null;
}
// 反馈审核台顶部各状态计数(pending 含历史 new 态)。后端 GET /admin/api/feedbacks/summary。
export interface FeedbackSummary {
pending: number;
adopted: number;
rejected: number;
total: number;
}
// 反馈页「加群二维码」卡配置(运营后台改 → App 意见反馈页同步)。
@@ -369,7 +385,9 @@ export interface AdRevenueRecord {
export interface AdRevenueDaily {
date: string; // 北京时间 YYYY-MM-DD
impressions: number;
revenue_yuan: number;
revenue_yuan: number; // 客户端预估收益(eCPM 折算)
pangle_revenue_yuan: number | null; // 穿山甲后台预估收益(GroMore revenue);非全量视图/无数据为 null
pangle_api_revenue_yuan: number | null; // 穿山甲收益API(更接近结算);未配/当天/无数据为 null
expected_coin: number;
actual_coin: number;
}
@@ -423,21 +441,23 @@ export interface AdRevenueReport {
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
// 可选:后端预聚合的「按广告类型」富小计(含发放金币明细)。当前后端未下发 → 数据大盘页
// (dashboard adTypeSummary)用 `?.` 探测、缺失时按 items 现算兜底;后端补上即自动启用。
by_ad_type?: Record<
string,
{ ad_type: string; impressions: number; revenue_yuan: number; expected_coin: number; actual_coin: number }
>;
dau: number | null; // 今日活跃(复用大盘 last_login_at);仅查询=今日时有值,否则 null
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
total_impressions: number;
total_revenue_yuan: number;
total_revenue_yuan: number; // 客户端预估收益合计(eCPM 折算)
total_pangle_revenue_yuan: number | null; // 穿山甲后台预估收益合计(GroMore revenue);非全量视图/无数据为 null
total_pangle_api_revenue_yuan: number | null; // 穿山甲收益API合计(更接近结算);未配/当天/非全量视图为 null
pangle_revenue_available: boolean; // 本次结果是否带穿山甲后台收益(全量视图且已同步到数据)
total_expected_coin: number;
total_actual_coin: number;
mismatch_count: number; // 应发≠实发的发奖条数
by_ad_type?: Record<string, {
ad_type: string;
impressions: number;
revenue_yuan: number;
expected_coin: number;
actual_coin: number;
}>;
items: AdRevenueRow[];
}
@@ -451,15 +471,15 @@ export interface DashboardOverview {
dau: number;
};
coins: {
reward_video_coin_total: number;
reward_video_watch_count: number;
feed_ad_coin_total: number;
feed_ad_watch_count: number;
signin_coin_total: number;
signin_count: number;
signin_boost_coin_total: number;
signin_boost_watch_count: number;
granted_total: number;
reward_video_coin_total?: number;
reward_video_watch_count?: number;
feed_ad_coin_total?: number;
feed_ad_watch_count?: number;
signin_coin_total?: number;
signin_count?: number;
signin_boost_coin_total?: number;
signin_boost_watch_count?: number;
};
cash: {
withdraw_success_cents: number;
@@ -468,6 +488,7 @@ export interface DashboardOverview {
withdraw_failed_count: number;
};
comparison: { total: number; success: number; success_rate: number };
// 时段大盘(对应后端 app/admin/schemas/dashboard.py DashboardPeriod);新版数据大盘页用
period: {
date_from: string;
date_to: string;
@@ -497,26 +518,29 @@ export interface DashboardOverview {
comparison_reward_coin_total: number;
regular_task_coin_total: number;
};
cash: {
withdraw_success_cents: number;
};
trend: Array<{
cash: { withdraw_success_cents: number };
trend: {
date: string;
active_users: number;
new_users: number;
comparisons: number;
}>;
}[];
};
feedback: { new: number };
cps: {
available: boolean;
note: string;
meituan_order_count?: number;
meituan_commission_cents?: number;
meituan_hit_count?: number;
meituan_miss_count?: number;
meituan_unknown_rate_count?: number;
meituan_hit_rate?: number | null;
meituan_order_count: number;
meituan_commission_cents: number;
meituan_hit_count: number;
meituan_miss_count: number;
meituan_unknown_rate_count: number;
meituan_hit_rate: number | null;
jd_order_count?: number;
jd_commission_cents?: number;
jd_actual_commission_cents?: number;
jd_estimated_commission_cents?: number;
jd_invalid_count?: number;
};
}
@@ -538,6 +562,16 @@ export interface PriceReport {
reward_coins: number | null;
reviewed_at: string | null;
created_at: string;
// 联表瞬态:phone/nickname 展示完整手机号(点手机号查该用户全部上报);
// 其余取自关联比价记录(无关联记录时为 null):trace 调试链接、机型OS版本、提交版本号
phone: string | null;
nickname: string | null;
trace_id: string | null;
trace_url: string | null;
device_model: string | null; // 机型,如 PEEM00
rom_name: string | null; // ROM/OS 名,如 ColorOS / MIUI / HarmonyOS
android_version: string | null; // Android 版本号,如 13
app_version: string | null; // 提交时我们 app 的 versionName,如 1.2.3
}
export interface PriceReportSummary {