Compare commits

..

2 Commits

Author SHA1 Message Date
OuYingJun1024 24293982bd feat(ad-revenue): 状态标签补不发原因 + eCPM 列标注单位(分)
- 发奖明细状态映射加 closed_early→「提前关闭」/ too_short→「未满10秒」,并全部转中文
- 展示/发奖明细 eCPM 列名改「eCPM(分)」(穿山甲原值单位为分,非元)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:42:01 +08:00
OuYingJun1024 c73cd8dabd feat(ad-revenue): 广告数据报表页(替换金币审计页)
- 新增 /ad-revenue:聚合表 + 派生指标(平均 eCPM/毛利/发奖占比)+ 零依赖 SVG 趋势图
  + 行展开下钻展示明细/发奖复算明细
- 删除旧 /ad-audit 页;侧栏「金币审计」→「广告数据」
- feedbacks 由「加载更多」改 antd 页码分页(usePagedList),内容列 3 行省略可展开
- types 用 AdRevenue* 替换 AdCoinAudit*;providers 统一主题圆角/页面底色

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:56:02 +08:00
14 changed files with 36 additions and 55 deletions
+1 -2
View File
@@ -3,7 +3,6 @@
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Alert,
Button,
Card,
@@ -20,6 +19,7 @@ import {
Table,
Tag,
Typography,
message,
} from 'antd';
import {
FallOutlined,
@@ -269,7 +269,6 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
export default function AdRevenuePage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
+3 -4
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd';
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdminInfo } from '@/lib/types';
@@ -16,7 +16,6 @@ const ROLES = [
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
export default function AdminsPage() {
const { message, modal } = App.useApp();
const [admins, setAdmins] = useState<AdminInfo[]>([]);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
@@ -50,7 +49,7 @@ export default function AdminsPage() {
const changeRole = (a: AdminInfo, role: string) => {
if (role === a.role) return;
modal.confirm({
Modal.confirm({
title: `${a.username} 的角色改为 ${role}?`,
onOk: async () => {
try {
@@ -66,7 +65,7 @@ export default function AdminsPage() {
const toggle = (a: AdminInfo) => {
const next = a.status === 'active' ? 'disabled' : 'active';
modal.confirm({
Modal.confirm({
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
onOk: async () => {
try {
+3 -10
View File
@@ -1,14 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd';
import { api, errMsg } from '@/lib/api';
interface ConfigItem {
key: string;
label: string;
group: string;
type: string; // int / int_list / dict_str_int / bool
type: string; // int / int_list / dict_str_int
help: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: any;
@@ -28,7 +28,6 @@ function toEdit(item: ConfigItem): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fromEdit(type: string, raw: any): any {
if (type === 'int') return Number(raw);
if (type === 'bool') return Boolean(raw);
if (type === 'int_list') {
return String(raw)
.split(',')
@@ -39,7 +38,6 @@ function fromEdit(type: string, raw: any): any {
}
export default function ConfigPage() {
const { message } = App.useApp();
const [items, setItems] = useState<ConfigItem[]>([]);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -111,12 +109,7 @@ export default function ConfigPage() {
)}
</div>
<Space wrap>
{item.type === 'bool' ? (
<Switch
checked={!!edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
/>
) : item.type === 'int' ? (
{item.type === 'int' ? (
<InputNumber
value={edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Form,
Input,
@@ -13,6 +12,7 @@ import {
Spin,
Switch,
Table,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
@@ -51,7 +51,6 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
export default function HomeMarqueeSeeds() {
const { message } = App.useApp();
const [seeds, setSeeds] = useState<Seed[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
+3 -3
View File
@@ -2,12 +2,12 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Checkbox,
Col,
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
@@ -16,6 +16,7 @@ import {
Spin,
Tag,
Tooltip,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
@@ -248,7 +249,6 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record<string,
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
export default function HomeStatsConfig() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<StatItem[]>([]);
const [loading, setLoading] = useState(true);
const [edits, setEdits] = useState<Record<string, Edit>>({});
@@ -321,7 +321,7 @@ export default function HomeStatsConfig() {
// 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
const rel = checkRelations(items, edits);
if (rel && rel.warnings.length > 0) {
modal.confirm({
Modal.confirm({
title: '数据关系校验',
width: 480,
okText: '仍要这样设置',
+2 -3
View File
@@ -1,7 +1,7 @@
'use client';
import type { ColumnsType } from 'antd/es/table';
import { App, Button, Popconfirm, Space, Table } from 'antd';
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
@@ -13,7 +13,6 @@ const dt = (v: string) => formatUtcTime(v);
const EMPTY: Record<string, unknown> = {};
export default function DevicesPage() {
const { message, modal } = App.useApp();
// 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。
const { items, loading, reload } = useCursorList<DeviceOnboardingItem>(
'/admin/api/onboarding/devices',
@@ -32,7 +31,7 @@ export default function DevicesPage() {
};
const resetAll = () => {
modal.confirm({
Modal.confirm({
title: '确认全部重设新手引导?',
content:
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
+3 -3
View File
@@ -5,16 +5,17 @@ import type { Key } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
App,
Button,
DatePicker,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
@@ -33,7 +34,6 @@ const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
type SortField = 'id' | 'created_at';
export default function FeedbacksPage() {
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
const [userId, setUserId] = useState('');
@@ -114,7 +114,7 @@ export default function FeedbacksPage() {
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
return;
}
modal.confirm({
Modal.confirm({
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
content: '仅对选中的待处理(new)反馈生效',
onOk: async () => {
+2 -3
View File
@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Image,
@@ -14,6 +13,7 @@ import {
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
@@ -57,7 +57,6 @@ function statusTag(status: string) {
}
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);
@@ -89,7 +88,7 @@ export default function PriceReportsPage() {
};
const approve = (r: PriceReport) => {
modal.confirm({
Modal.confirm({
title: '确认通过该上报?',
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
content: (
+3 -3
View File
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Button,
Card,
Col,
Descriptions,
Modal,
Result,
Row,
Space,
@@ -17,6 +17,7 @@ import {
Table,
Tabs,
Tag,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
@@ -26,7 +27,6 @@ import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModa
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
export default function UserDetailPage() {
const { message, modal } = App.useApp();
const params = useParams<{ id: string }>();
const uid = Number(params.id);
const [data, setData] = useState<UserOverview | null>(null);
@@ -56,7 +56,7 @@ export default function UserDetailPage() {
const toggleStatus = () => {
if (!data) return;
const next = data.user.status === 'active' ? 'disabled' : 'active';
modal.confirm({
Modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
onOk: async () => {
try {
+5 -6
View File
@@ -5,7 +5,7 @@ import type { Key } from 'react';
import { useRouter } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
@@ -22,7 +22,6 @@ type SortField = 'id' | 'created_at' | 'last_login_at';
export default function UsersPage() {
const router = useRouter();
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
const [phone, setPhone] = useState('');
@@ -98,7 +97,7 @@ export default function UsersPage() {
const toggleStatus = (u: UserListItem) => {
const next = u.status === 'active' ? 'disabled' : 'active';
modal.confirm({
Modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
onOk: async () => {
try {
@@ -114,7 +113,7 @@ export default function UsersPage() {
const toggleDebugTrace = (u: UserListItem) => {
const next = !u.debug_trace_enabled;
modal.confirm({
Modal.confirm({
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
content: next
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
@@ -157,7 +156,7 @@ export default function UsersPage() {
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
return;
}
modal.confirm({
Modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
okButtonProps: { danger: target === 'disabled' },
@@ -178,7 +177,7 @@ export default function UsersPage() {
message.warning('没有需要变更的选中用户');
return;
}
modal.confirm({
Modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
onOk: () =>
runBulk(targets, label, (u) =>
+6 -7
View File
@@ -3,7 +3,6 @@
import { useCallback, useEffect, useState } from 'react';
import type { Key } from 'react';
import {
App,
Alert,
Button,
Card,
@@ -23,6 +22,7 @@ import {
Tag,
Timeline,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -169,7 +169,6 @@ function bulkResultText(action: string, result: WithdrawBulkResult) {
}
export default function WithdrawsPage() {
const { message, modal } = App.useApp();
const [activeStatus, setActiveStatus] = useState('reviewing');
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
@@ -306,7 +305,7 @@ export default function WithdrawsPage() {
};
const approve = (o: WithdrawOrder) => {
modal.confirm({
Modal.confirm({
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
content: (
<div>
@@ -369,7 +368,7 @@ export default function WithdrawsPage() {
}
const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0);
let confirmText = '';
modal.confirm({
Modal.confirm({
title: `确认批量通过 ${selectedReviewing.length} 笔提现?`,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
@@ -407,7 +406,7 @@ export default function WithdrawsPage() {
message.warning('请选择打款中的提现单');
return;
}
modal.confirm({
Modal.confirm({
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
content: '会逐笔向微信查单,并按最新状态归一化。',
okText: '批量刷新查单',
@@ -429,7 +428,7 @@ export default function WithdrawsPage() {
};
const retry = (o: WithdrawOrder) => {
modal.confirm({
Modal.confirm({
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
okText: '刷新查单',
@@ -451,7 +450,7 @@ export default function WithdrawsPage() {
};
const reconcile = () => {
modal.confirm({
Modal.confirm({
title: '批量对账?',
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
okText: '开始对账',
+1 -2
View File
@@ -2,14 +2,13 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { App, Button, Card, Form, Input } from 'antd';
import { Button, Card, Form, Input, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { setAuth } from '@/lib/auth';
import type { LoginResponse } from '@/lib/types';
export default function LoginPage() {
const router = useRouter();
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const onFinish = async (values: { username: string; password: string }) => {
+2 -4
View File
@@ -5,7 +5,7 @@
// 必须在引入 antd 之前 import。
import '@ant-design/v5-patch-for-react-19';
import { AntdRegistry } from '@ant-design/nextjs-registry';
import { App, ConfigProvider } from 'antd';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
export function Providers({ children }: { children: React.ReactNode }) {
@@ -23,9 +23,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
},
}}
>
{/* antd <App> : message/notification/Modal App.useApp() ConfigProvider
/locale, "Static function can not consume context" () */}
<App>{children}</App>
{children}
</ConfigProvider>
</AntdRegistry>
);
+1 -3
View File
@@ -3,7 +3,7 @@
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
import { useEffect, useState } from 'react';
import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd';
import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { yuan } from '@/lib/format';
import type { UserOverview } from '@/lib/types';
@@ -47,7 +47,6 @@ function useBalances(userId: number | undefined, open: boolean) {
}
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
@@ -111,7 +110,6 @@ export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
}
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);