Compare commits

...

4 Commits

Author SHA1 Message Date
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
9 changed files with 658 additions and 150 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>
@@ -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>
);
}
+57 -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,24 @@ 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;
};
}
@@ -538,6 +557,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 {