Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1bdb66a76 | |||
| c905e15ec9 | |||
| dd84d771f9 |
@@ -490,7 +490,7 @@ export default function AdRevenueReportPage() {
|
|||||||
|
|
||||||
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
|
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
|
||||||
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
|
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
|
||||||
// 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 预估收益÷今日DAU(仅今日)。
|
// 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 区间预估收益÷区间活跃用户(见 dau)。
|
||||||
const derived = data
|
const derived = data
|
||||||
? {
|
? {
|
||||||
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
|
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
|
||||||
@@ -500,11 +500,23 @@ export default function AdRevenueReportPage() {
|
|||||||
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
|
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
|
||||||
: null,
|
: null,
|
||||||
coinGap: data.total_expected_coin - data.total_actual_coin,
|
coinGap: data.total_expected_coin - data.total_actual_coin,
|
||||||
// ARPU(今日)= 预估广告收益 ÷ 今日 DAU;dau 为 null(历史/多天)或 0 时不可算 → null,前端显示「-」。
|
// ARPU = 区间预估广告收益 ÷ 区间去重活跃用户(dau,口径同数据大盘);dau 为 0 时不可算 → null,显示「-」。
|
||||||
arpu: data.dau && data.dau > 0 ? data.total_revenue_yuan / data.dau : null,
|
arpu: data.dau && data.dau > 0 ? data.total_revenue_yuan / data.dau : null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// DAU / ARPU 的统计区间 = 本次查询的 date_from~date_to(后端 period_active_dau 同口径按此区间算);
|
||||||
|
// 今日单天特殊标注「今日」,便于「今日 / 近 7 天 / 近 30 天」三个时段分别看 ARPU。
|
||||||
|
const dauRangeIsToday =
|
||||||
|
!!data && data.date_from === data.date_to && data.date_to === dayjs().format('YYYY-MM-DD');
|
||||||
|
const dauRangeLabel = !data
|
||||||
|
? ''
|
||||||
|
: data.date_from === data.date_to
|
||||||
|
? dauRangeIsToday
|
||||||
|
? '今日'
|
||||||
|
: data.date_from
|
||||||
|
: `${data.date_from} ~ ${data.date_to}`;
|
||||||
|
|
||||||
// 第二行大盘「分广告类型」:draw / 激励视频 各自 收益 / eCPM / 展示条数;eCPM = 收益÷展示×1000。
|
// 第二行大盘「分广告类型」:draw / 激励视频 各自 收益 / eCPM / 展示条数;eCPM = 收益÷展示×1000。
|
||||||
const drawStat = data?.type_stats?.draw;
|
const drawStat = data?.type_stats?.draw;
|
||||||
const rvStat = data?.type_stats?.reward_video;
|
const rvStat = data?.type_stats?.reward_video;
|
||||||
@@ -657,7 +669,7 @@ export default function AdRevenueReportPage() {
|
|||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
|
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
核心指标
|
核心指标{dauRangeLabel ? ` · 统计区间 ${dauRangeLabel}` : ''}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Divider>
|
</Divider>
|
||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
@@ -667,9 +679,11 @@ export default function AdRevenueReportPage() {
|
|||||||
<Col flex="1 1 0">
|
<Col flex="1 1 0">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={
|
||||||
<Tooltip title="每活跃用户广告预估收入 = 预估收益 ÷ 今日 DAU;DAU 仅今日口径,历史/多天显示 -">
|
<Tooltip
|
||||||
|
title={`每活跃用户广告预估收入 = 区间预估收益 ÷ 区间去重活跃用户(口径同数据大盘)。统计区间:${dauRangeLabel || '—'}`}
|
||||||
|
>
|
||||||
<span>
|
<span>
|
||||||
ARPU(今日)
|
{dauRangeIsToday ? 'ARPU(今日)' : 'ARPU'}
|
||||||
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -681,9 +695,11 @@ export default function AdRevenueReportPage() {
|
|||||||
<Col flex="1 1 0">
|
<Col flex="1 1 0">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={
|
||||||
<Tooltip title="今日活跃用户(复用大盘口径 last_login_at = 今日登录过);仅查询=今日时有值,历史/多天显示 -">
|
<Tooltip
|
||||||
|
title={`区间去重活跃用户,口径同数据大盘:登录 + 开始比价 + 开始领券,按用户去重。全局口径,不随用户/类型/场景/应用筛选变化。统计区间:${dauRangeLabel || '—'}`}
|
||||||
|
>
|
||||||
<span>
|
<span>
|
||||||
今日活跃 DAU
|
{dauRangeIsToday ? '今日活跃 DAU' : '活跃用户'}
|
||||||
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { canDo } from '@/lib/auth';
|
|||||||
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
|
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { useCursorList } from '@/lib/useCursorList';
|
||||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||||
|
import { DeleteUserModal } from '@/components/DeleteUserModal';
|
||||||
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
@@ -33,6 +34,7 @@ export default function UserDetailPage() {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [coinOpen, setCoinOpen] = useState(false);
|
const [coinOpen, setCoinOpen] = useState(false);
|
||||||
const [cashOpen, setCashOpen] = useState(false);
|
const [cashOpen, setCashOpen] = useState(false);
|
||||||
|
const [delOpen, setDelOpen] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
@@ -52,6 +54,7 @@ export default function UserDetailPage() {
|
|||||||
|
|
||||||
const canCoins = canDo(['finance']);
|
const canCoins = canDo(['finance']);
|
||||||
const canStatus = canDo(['operator']);
|
const canStatus = canDo(['operator']);
|
||||||
|
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
|
||||||
|
|
||||||
const toggleStatus = () => {
|
const toggleStatus = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -154,6 +157,11 @@ export default function UserDetailPage() {
|
|||||||
{data.user.status === 'active' ? '封禁' : '解封'}
|
{data.user.status === 'active' ? '封禁' : '解封'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canDelete && data.user.status !== 'deleted' && (
|
||||||
|
<Button danger onClick={() => setDelOpen(true)}>
|
||||||
|
注销账号
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
<Card style={{ marginBottom: 16 }}>
|
<Card style={{ marginBottom: 16 }}>
|
||||||
@@ -272,6 +280,12 @@ export default function UserDetailPage() {
|
|||||||
cash.reload();
|
cash.reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<DeleteUserModal
|
||||||
|
user={data.user}
|
||||||
|
open={delOpen}
|
||||||
|
onClose={() => setDelOpen(false)}
|
||||||
|
onDone={loadData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { canDo } from '@/lib/auth';
|
|||||||
import { formatUtcTime } from '@/lib/format';
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import { usePagedList } from '@/lib/usePagedList';
|
import { usePagedList } from '@/lib/usePagedList';
|
||||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||||
|
import { DeleteUserModal } from '@/components/DeleteUserModal';
|
||||||
import type { UserListItem } from '@/lib/types';
|
import type { UserListItem } from '@/lib/types';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@@ -43,9 +44,11 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
const canCoins = canDo(['finance']);
|
const canCoins = canDo(['finance']);
|
||||||
const canStatus = canDo(['operator']);
|
const canStatus = canDo(['operator']);
|
||||||
|
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
|
||||||
|
|
||||||
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
||||||
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
||||||
|
const [delUser, setDelUser] = useState<UserListItem | null>(null);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||||
|
|
||||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||||
@@ -253,7 +256,7 @@ export default function UsersPage() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'op',
|
key: 'op',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 300,
|
width: 340,
|
||||||
render: (_: unknown, u: UserListItem) => (
|
render: (_: unknown, u: UserListItem) => (
|
||||||
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
|
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
|
||||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||||
@@ -276,6 +279,11 @@ export default function UsersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<a onClick={() => setForceOnboarding(u, true)}>开启新手引导</a>
|
<a onClick={() => setForceOnboarding(u, true)}>开启新手引导</a>
|
||||||
))}
|
))}
|
||||||
|
{canDelete && u.status !== 'deleted' && (
|
||||||
|
<a style={{ color: '#cf1322' }} onClick={() => setDelUser(u)}>
|
||||||
|
注销
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -384,12 +392,18 @@ export default function UsersPage() {
|
|||||||
showTotal: (t) => `共 ${t} 个用户`,
|
showTotal: (t) => `共 ${t} 个用户`,
|
||||||
onChange: onPageChange,
|
onChange: onPageChange,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1160 }}
|
scroll={{ x: 1200 }}
|
||||||
onChange={onTableChange}
|
onChange={onTableChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
||||||
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
||||||
|
<DeleteUserModal
|
||||||
|
user={delUser}
|
||||||
|
open={!!delUser}
|
||||||
|
onClose={() => setDelUser(null)}
|
||||||
|
onDone={reload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// 注销/删除账号弹窗(受控)。列表页与用户详情页共用,避免两处各写一套(同 AdjustBalanceModals)。
|
||||||
|
// 复用 C 端自助注销同一套后端清理:物理删除个人内容/行为/PII 表 + 清零余额 + 匿名化 user 行,
|
||||||
|
// 保留资金流水/邀请关系/广告幂等+对账表。仅 super_admin 可见入口;后端亦 require_role("super_admin") 兜底。
|
||||||
|
// 后端资金前置闸(有未提现现金/邀请金 / 在审提现单 → 409)带明确 detail,errMsg 原样透出。
|
||||||
|
import { App, Form, Input, Modal } from 'antd';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
|
||||||
|
export type DeleteTargetUser = { id: number; phone: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: DeleteTargetUser | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone?: () => void; // 删除成功后回调(刷新详情/列表)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteUserModal({ user, open, onClose, onDone }: Props) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
const v = await form.validateFields(); // 原因必填,缺失即红字阻断
|
||||||
|
try {
|
||||||
|
// axios 的 DELETE 带 body 要放在 config.data 里
|
||||||
|
await api.delete(`/admin/api/users/${user.id}`, { data: { reason: v.reason } });
|
||||||
|
message.success('账号已注销');
|
||||||
|
onClose();
|
||||||
|
onDone?.();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`注销账号 - ${user?.phone ?? ''}`}
|
||||||
|
open={open}
|
||||||
|
onOk={submit}
|
||||||
|
onCancel={onClose}
|
||||||
|
okText="确认注销"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
afterOpenChange={(o) => {
|
||||||
|
if (o) form.resetFields();
|
||||||
|
}}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<p style={{ color: '#cf1322', marginBottom: 12 }}>
|
||||||
|
将永久删除该账号的个人数据(比价/省钱/签到/领券/埋点/设备/邀请指纹等)并清零余额、匿名化手机号,
|
||||||
|
<strong>不可恢复</strong>。资金流水/邀请关系/广告对账记录按合规保留。
|
||||||
|
</p>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="reason" label="注销原因(入审计)" rules={[{ required: true, message: '请填写注销原因' }]}>
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -486,7 +486,7 @@ export interface AdRevenueReport {
|
|||||||
string,
|
string,
|
||||||
{ ad_type: string; impressions: number; revenue_yuan: number; expected_coin: number; actual_coin: number }
|
{ ad_type: string; impressions: number; revenue_yuan: number; expected_coin: number; actual_coin: number }
|
||||||
>;
|
>;
|
||||||
dau: number | null; // 今日活跃(复用大盘 last_login_at);仅查询=今日时有值,否则 null
|
dau: number | null; // 区间去重活跃用户(口径同数据大盘 period.users.active:登录+开始比价+开始领券);按查询区间统计、含今日,全局口径不随筛选变化
|
||||||
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
|
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
|
||||||
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
|
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
|
||||||
total_impressions: number;
|
total_impressions: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user