Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32fde71c96 | |||
| bc677817e4 | |||
| 33c80d6927 | |||
| 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>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ function genPassword(len = 10): string {
|
|||||||
|
|
||||||
type View = 'list' | 'person' | 'roles' | 'roleForm';
|
type View = 'list' | 'person' | 'roles' | 'roleForm';
|
||||||
|
|
||||||
|
// 「自定义」哨兵角色:不是共享角色,选它时可见页由本人逐页勾选(存 pages_override),与后端一致。
|
||||||
|
const CUSTOM_ROLE = 'custom';
|
||||||
|
const CUSTOM_LABEL = '自定义';
|
||||||
|
|
||||||
// ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)=====
|
// ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)=====
|
||||||
function PermMatrix({ catalog, pages }: { catalog: PermissionGroup[]; pages: string[] }) {
|
function PermMatrix({ catalog, pages }: { catalog: PermissionGroup[]; pages: string[] }) {
|
||||||
const has = useMemo(() => new Set(pages), [pages]);
|
const has = useMemo(() => new Set(pages), [pages]);
|
||||||
@@ -109,6 +113,11 @@ export default function AdminsPage() {
|
|||||||
return m;
|
return m;
|
||||||
}, [roles]);
|
}, [roles]);
|
||||||
const roleOptions = roles.map((r) => ({ value: r.name, label: r.label }));
|
const roleOptions = roles.map((r) => ({ value: r.name, label: r.label }));
|
||||||
|
// 人员表单角色下拉:真实角色 + 末尾「自定义」。选「自定义」→ 下方可见页改为逐页勾选。
|
||||||
|
const personRoleOptions = [...roleOptions, { value: CUSTOM_ROLE, label: CUSTOM_LABEL }];
|
||||||
|
// 角色 key → 展示名(custom 显示「自定义」;查不到回退 key 原文)
|
||||||
|
const roleLabelOf = (name: string) =>
|
||||||
|
(name === CUSTOM_ROLE ? CUSTOM_LABEL : roleByName[name]?.label ?? name);
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -134,11 +143,14 @@ export default function AdminsPage() {
|
|||||||
const [personName, setPersonName] = useState('');
|
const [personName, setPersonName] = useState('');
|
||||||
const [personRole, setPersonRole] = useState('');
|
const [personRole, setPersonRole] = useState('');
|
||||||
const [initPw, setInitPw] = useState('');
|
const [initPw, setInitPw] = useState('');
|
||||||
|
// 「自定义」角色时逐页勾选的可见页集(role==custom 才用到)
|
||||||
|
const [personPages, setPersonPages] = useState<string[]>([]);
|
||||||
|
|
||||||
const openCreatePerson = () => {
|
const openCreatePerson = () => {
|
||||||
setPersonEditing(null);
|
setPersonEditing(null);
|
||||||
setPersonName('');
|
setPersonName('');
|
||||||
setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator');
|
setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator');
|
||||||
|
setPersonPages([]);
|
||||||
setInitPw(genPassword());
|
setInitPw(genPassword());
|
||||||
setView('person');
|
setView('person');
|
||||||
};
|
};
|
||||||
@@ -146,6 +158,8 @@ export default function AdminsPage() {
|
|||||||
setPersonEditing(a);
|
setPersonEditing(a);
|
||||||
setPersonName(a.username);
|
setPersonName(a.username);
|
||||||
setPersonRole(a.role);
|
setPersonRole(a.role);
|
||||||
|
// 编辑「自定义」用户时,回填其已勾选的可见页(供继续增删)
|
||||||
|
setPersonPages(a.role === CUSTOM_ROLE ? (a.pages_override ?? []) : []);
|
||||||
setInitPw(''); // 编辑态密码只读展示 a.password(已确定的登录密码),不用 initPw
|
setInitPw(''); // 编辑态密码只读展示 a.password(已确定的登录密码),不用 initPw
|
||||||
setView('person');
|
setView('person');
|
||||||
};
|
};
|
||||||
@@ -160,18 +174,30 @@ export default function AdminsPage() {
|
|||||||
<Button onClick={() => copyText(value)}>复制</Button>
|
<Button onClick={() => copyText(value)}>复制</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
const isCustom = personRole === CUSTOM_ROLE;
|
||||||
const submitPerson = async () => {
|
const submitPerson = async () => {
|
||||||
|
if (isCustom && personPages.length === 0) { message.error('自定义角色请至少勾选一个可见页面'); return; }
|
||||||
try {
|
try {
|
||||||
if (personEditing) {
|
if (personEditing) {
|
||||||
// 密码只读、不改;只提交变更的角色
|
// 密码只读、不改;提交变更的角色 +(自定义时)勾选的可见页
|
||||||
if (personRole !== personEditing.role) {
|
const roleChanged = personRole !== personEditing.role;
|
||||||
await api.patch(`/admin/api/admins/${personEditing.id}`, { role: personRole });
|
const pagesChanged = isCustom
|
||||||
|
&& JSON.stringify(personPages) !== JSON.stringify(personEditing.pages_override ?? []);
|
||||||
|
if (roleChanged || pagesChanged) {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (roleChanged) payload.role = personRole;
|
||||||
|
// 自定义:带上勾选集(切到 custom 或改现有 custom 勾选都要);切回普通角色不带,后端会清空 override
|
||||||
|
if (isCustom) payload.pages_override = personPages;
|
||||||
|
await api.patch(`/admin/api/admins/${personEditing.id}`, payload);
|
||||||
}
|
}
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
} else {
|
} else {
|
||||||
if (personName.trim().length < 3) { message.error('用户名至少 3 位'); return; }
|
if (personName.trim().length < 3) { message.error('用户名至少 3 位'); return; }
|
||||||
await api.post('/admin/api/admins', {
|
await api.post('/admin/api/admins', {
|
||||||
username: personName.trim(), password: initPw, role: personRole,
|
username: personName.trim(),
|
||||||
|
password: initPw,
|
||||||
|
role: personRole,
|
||||||
|
...(isCustom ? { pages_override: personPages } : {}),
|
||||||
});
|
});
|
||||||
message.success('已创建,请把初始密码转交本人');
|
message.success('已创建,请把初始密码转交本人');
|
||||||
}
|
}
|
||||||
@@ -184,8 +210,10 @@ export default function AdminsPage() {
|
|||||||
// ===== 列表操作 =====
|
// ===== 列表操作 =====
|
||||||
const changeRole = (a: AdminInfo, role: string) => {
|
const changeRole = (a: AdminInfo, role: string) => {
|
||||||
if (role === a.role) return;
|
if (role === a.role) return;
|
||||||
|
// 改成「自定义」需要逐页勾选 → 转去编辑页(列表里没有页选择器,直接切会得到空可见页)
|
||||||
|
if (role === CUSTOM_ROLE) { openEditPerson({ ...a, role: CUSTOM_ROLE }); return; }
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `把 ${a.username} 的角色改为 ${role}?`,
|
title: `把 ${a.username} 的角色改为 ${roleLabelOf(role)}?`,
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); }
|
try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); }
|
||||||
catch (e) { message.error(errMsg(e)); }
|
catch (e) { message.error(errMsg(e)); }
|
||||||
@@ -266,8 +294,9 @@ export default function AdminsPage() {
|
|||||||
{ title: '用户名', dataIndex: 'username' },
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
{
|
{
|
||||||
title: '角色', dataIndex: 'role', width: 170,
|
title: '角色', dataIndex: 'role', width: 170,
|
||||||
|
// options 含「自定义」→ custom 用户在列表里显示为「自定义」;选它会转去编辑页勾选(见 changeRole)
|
||||||
render: (r: string, a: AdminInfo) => (
|
render: (r: string, a: AdminInfo) => (
|
||||||
<Select size="small" value={r} style={{ width: 150 }} options={roleOptions} onChange={(v) => changeRole(a, v)} />
|
<Select size="small" value={r} style={{ width: 150 }} options={personRoleOptions} onChange={(v) => changeRole(a, v)} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? 'active' : '已禁用'}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? 'active' : '已禁用'}</Tag> },
|
||||||
@@ -341,16 +370,26 @@ export default function AdminsPage() {
|
|||||||
<span>选择角色<span style={{ color: '#cf1322' }}> *</span></span>
|
<span>选择角色<span style={{ color: '#cf1322' }}> *</span></span>
|
||||||
{isSuper && <a onClick={openRoles}>设置角色权限 ›</a>}
|
{isSuper && <a onClick={openRoles}>设置角色权限 ›</a>}
|
||||||
</div>
|
</div>
|
||||||
<Select value={personRole} options={roleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
|
<Select value={personRole} options={personRoleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
|
||||||
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色</div>
|
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>
|
||||||
|
{isCustom
|
||||||
|
? '「自定义」:下方逐页勾选该成员可见的页面,仅对本人生效(不影响任何角色)'
|
||||||
|
: '可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<span>可见页面</span>
|
<span>可见页面</span>
|
||||||
<span style={{ color: '#999', fontSize: 12 }}>跟随所选角色,此处只读(深色=可见)</span>
|
<span style={{ color: '#999', fontSize: 12 }}>
|
||||||
|
{isCustom ? '勾选本成员可见的页面' : '跟随所选角色,此处只读(深色=可见)'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<PermMatrix catalog={catalog} pages={personRole === 'super_admin' ? catalog.flatMap((g) => g.pages.map((p) => p.key)) : rolePagesPreview} />
|
{isCustom ? (
|
||||||
|
<PermEditor catalog={catalog} value={personPages} onChange={setPersonPages} />
|
||||||
|
) : (
|
||||||
|
<PermMatrix catalog={catalog} pages={personRole === 'super_admin' ? catalog.flatMap((g) => g.pages.map((p) => p.key)) : rolePagesPreview} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||||
|
|||||||
@@ -42,13 +42,15 @@ interface BulkVals {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreviewItem {
|
interface RealRecord {
|
||||||
masked_user: string;
|
masked_user: string;
|
||||||
saved_amount_cents: number;
|
saved_amount_cents: number;
|
||||||
time: string;
|
created_at: string;
|
||||||
|
user_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const yuan = (cents: number) => (cents / 100).toFixed(2);
|
const yuan = (cents: number) => (cents / 100).toFixed(2);
|
||||||
|
const MODE_LABEL: Record<string, string> = { mixed: '混播', real: '只真实', seed: '只种子' };
|
||||||
|
|
||||||
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「系统配置 / 首页」里的一个区块。 */
|
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「系统配置 / 首页」里的一个区块。 */
|
||||||
export default function HomeMarqueeSeeds() {
|
export default function HomeMarqueeSeeds() {
|
||||||
@@ -66,9 +68,12 @@ export default function HomeMarqueeSeeds() {
|
|||||||
const [bulkOpen, setBulkOpen] = useState(false);
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
const [bulkForm] = Form.useForm<BulkVals>();
|
const [bulkForm] = Form.useForm<BulkVals>();
|
||||||
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
// 「可展示的真实记录」分页浏览(替代原随机预览):翻遍所有 success+省>0 的真实记录
|
||||||
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
|
const REC_PAGE_SIZE = 8;
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [recItems, setRecItems] = useState<RealRecord[]>([]);
|
||||||
|
const [recTotal, setRecTotal] = useState(0);
|
||||||
|
const [recPage, setRecPage] = useState(0); // 0-based
|
||||||
|
const [recLoading, setRecLoading] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -94,6 +99,7 @@ export default function HomeMarqueeSeeds() {
|
|||||||
const changeMode = async (m: string) => {
|
const changeMode = async (m: string) => {
|
||||||
const prev = mode;
|
const prev = mode;
|
||||||
setMode(m);
|
setMode(m);
|
||||||
|
setRecPage(0); // 切模式回第 1 页(不同模式条数不同,避免停在越界页)
|
||||||
setModeSaving(true);
|
setModeSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.patch('/admin/api/marquee-seeds/mode', { mode: m });
|
await api.patch('/admin/api/marquee-seeds/mode', { mode: m });
|
||||||
@@ -168,22 +174,29 @@ export default function HomeMarqueeSeeds() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== 预览实际 feed =====
|
// ===== 可展示的记录:按「当前模式」分页浏览全部(不去重)=====
|
||||||
const fetchPreview = async () => {
|
// 只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,
|
||||||
setPreviewLoading(true);
|
// 后端固定种子 → 翻页稳定、能翻遍全部。
|
||||||
|
const fetchRecords = async (page: number, m: string) => {
|
||||||
|
setRecLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<{ items: PreviewItem[] }>('/admin/api/marquee-seeds/preview?limit=8');
|
const { data } = await api.get<{ items: RealRecord[]; total: number }>(
|
||||||
setPreviewItems(data.items);
|
'/admin/api/marquee-seeds/real-records',
|
||||||
|
{ params: { offset: page * REC_PAGE_SIZE, limit: REC_PAGE_SIZE, mode: m } },
|
||||||
|
);
|
||||||
|
setRecItems(data.items);
|
||||||
|
setRecTotal(data.total);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(errMsg(e));
|
message.error(errMsg(e));
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false);
|
setRecLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const openPreview = () => {
|
// 页码或模式变化都重拉(切模式时 changeMode 会把页码重置到 0)。
|
||||||
setPreviewOpen(true);
|
useEffect(() => {
|
||||||
fetchPreview();
|
fetchRecords(recPage, mode);
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [recPage, mode]);
|
||||||
|
|
||||||
const toggle = async (s: Seed, enabled: boolean) => {
|
const toggle = async (s: Seed, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
@@ -300,6 +313,84 @@ export default function HomeMarqueeSeeds() {
|
|||||||
混播=真实优先+种子补位;只真实=不掺假数据(真实不足则少显示);只种子=只用下方种子/合成(演示用)
|
混播=真实优先+种子补位;只真实=不掺假数据(真实不足则少显示);只种子=只用下方种子/合成(演示用)
|
||||||
</span>
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
{/* 可展示的真实记录:分页浏览全部(success+省>0,不去重、稳定倒序);app 轮播即从这些记录
|
||||||
|
随机打乱、同一用户去连簇后展示。翻页可看全所有真实用户的展示信息。 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||||
|
可展示的记录 · {MODE_LABEL[mode] ?? mode} · 共 {recTotal} 条
|
||||||
|
<span style={{ color: '#999', fontWeight: 400, fontSize: 12, marginLeft: 8 }}>
|
||||||
|
当前模式下 app 轮播能露出的全部记录(不去重):只真实=真实、只种子=各种子生成、混播=真实+种子;
|
||||||
|
已按 app 口径洗牌+去连簇,顺序固定便于翻页看全
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" disabled={recPage <= 0} onClick={() => setRecPage((p) => Math.max(0, p - 1))}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span style={{ fontSize: 12, color: '#666' }}>
|
||||||
|
第 {recTotal === 0 ? 0 : recPage + 1}/{Math.max(1, Math.ceil(recTotal / REC_PAGE_SIZE))} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={(recPage + 1) * REC_PAGE_SIZE >= recTotal}
|
||||||
|
onClick={() => setRecPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Spin spinning={recLoading}>
|
||||||
|
<div style={{ minHeight: 120 }}>
|
||||||
|
{recItems.length === 0 && !recLoading ? (
|
||||||
|
<div style={{ color: '#bbb', textAlign: 'center', padding: '36px 0', fontSize: 13 }}>
|
||||||
|
当前模式暂无可展示的记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recItems.map((it, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 4px',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{it.masked_user} 比价后节省{' '}
|
||||||
|
<b style={{ color: '#fa541c' }}>{yuan(it.saved_amount_cents)} 元</b>
|
||||||
|
<span style={{ color: '#ccc', fontSize: 12, marginLeft: 8 }}>
|
||||||
|
{it.user_id > 0 ? `u${it.user_id}` : '种子'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#999', fontSize: 12 }}>{it.created_at}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Space style={{ marginBottom: 12 }} wrap>
|
<Space style={{ marginBottom: 12 }} wrap>
|
||||||
<Button type="primary" size="small" onClick={openAdd}>
|
<Button type="primary" size="small" onClick={openAdd}>
|
||||||
新增种子
|
新增种子
|
||||||
@@ -307,9 +398,6 @@ export default function HomeMarqueeSeeds() {
|
|||||||
<Button size="small" onClick={openBulk}>
|
<Button size="small" onClick={openBulk}>
|
||||||
批量生成
|
批量生成
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" onClick={openPreview}>
|
|
||||||
预览效果
|
|
||||||
</Button>
|
|
||||||
<span style={{ borderLeft: '1px solid #eee', height: 18 }} />
|
<span style={{ borderLeft: '1px solid #eee', height: 18 }} />
|
||||||
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(true)}>
|
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(true)}>
|
||||||
批量启用
|
批量启用
|
||||||
@@ -413,48 +501,6 @@ export default function HomeMarqueeSeeds() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 预览实际 feed */}
|
|
||||||
<Modal
|
|
||||||
title="预览轮播效果"
|
|
||||||
open={previewOpen}
|
|
||||||
onCancel={() => setPreviewOpen(false)}
|
|
||||||
footer={
|
|
||||||
<Space>
|
|
||||||
<Button size="small" onClick={fetchPreview}>
|
|
||||||
换一批
|
|
||||||
</Button>
|
|
||||||
<Button size="small" type="primary" onClick={() => setPreviewOpen(false)}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p style={{ color: '#999', marginTop: 0 }}>
|
|
||||||
客户端实际会看到的混播结果:真实记录会插队,种子随机抽取、金额/用户名每次都不同。点「换一批」可重新随机。
|
|
||||||
</p>
|
|
||||||
<Spin spinning={previewLoading}>
|
|
||||||
<div style={{ minHeight: 120 }}>
|
|
||||||
{previewItems.map((it, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '7px 4px',
|
|
||||||
borderBottom: '1px solid #f0f0f0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{it.masked_user} 比价后节省{' '}
|
|
||||||
<b style={{ color: '#fa541c' }}>{yuan(it.saved_amount_cents)} 元</b>
|
|
||||||
</span>
|
|
||||||
<span style={{ color: '#999' }}>{it.time}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Spin>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,9 +118,10 @@ function previousPeriodRange(range: ReturnType<typeof periodRange>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function retentionTitle(period: PeriodKey) {
|
function retentionTitle(period: PeriodKey) {
|
||||||
if (period === '7d') return '近 7 日新增留存';
|
// 次日留存口径(2026-07-05):每天取前一日新增用户,统计其当日活跃比例;单日窗口即「前日新增的昨日留存」。
|
||||||
if (period === '30d') return '近 30 日新增留存';
|
if (period === '7d') return '近 7 日新增次日留存';
|
||||||
return '昨日新增留存';
|
if (period === '30d') return '近 30 日新增次日留存';
|
||||||
|
return '前日用户新增留存';
|
||||||
}
|
}
|
||||||
|
|
||||||
function avgEcpm(report: AdRevenueReport | null) {
|
function avgEcpm(report: AdRevenueReport | null) {
|
||||||
@@ -131,19 +132,23 @@ function avgEcpm(report: AdRevenueReport | null) {
|
|||||||
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
|
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
|
||||||
if (!report) return null;
|
if (!report) return null;
|
||||||
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType];
|
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType];
|
||||||
|
// 优先用后端全量小计 type_stats(items 受 limit 分页截断,>1000 行的区间按 items 现算会漏旧数据)
|
||||||
|
const stat = report.type_stats?.[adType];
|
||||||
|
if (stat) return { impressions: stat.impressions, revenue_yuan: stat.revenue_yuan };
|
||||||
const matched = report.items.filter((it) => it.ad_type === adType);
|
const matched = report.items.filter((it) => it.ad_type === adType);
|
||||||
if (matched.length === 0) return null;
|
if (matched.length === 0) return null;
|
||||||
return {
|
return {
|
||||||
ad_type: adType,
|
|
||||||
impressions: matched.reduce((sum, it) => sum + it.impressions, 0),
|
impressions: matched.reduce((sum, it) => sum + it.impressions, 0),
|
||||||
revenue_yuan: matched.reduce((sum, it) => sum + it.revenue_yuan, 0),
|
revenue_yuan: matched.reduce((sum, it) => sum + it.revenue_yuan, 0),
|
||||||
expected_coin: matched.reduce((sum, it) => sum + it.expected_coin, 0),
|
|
||||||
actual_coin: matched.reduce((sum, it) => sum + it.actual_coin, 0),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function adSceneSummary(report: AdRevenueReport | null, feedScene: 'coupon' | 'comparison') {
|
function adSceneSummary(report: AdRevenueReport | null, feedScene: 'coupon' | 'comparison') {
|
||||||
if (!report) return null;
|
if (!report) return null;
|
||||||
|
// 优先用后端全量小计 scene_stats:2026-07-02 起 items 不再含信息流逐条展示行(唯一带收益+场景的行),
|
||||||
|
// 按 items 现算恒为 0;scene_stats 在全量 events 上聚合且不受分页截断。旧后端无此字段时回退 items 现算。
|
||||||
|
const stat = report.scene_stats?.[feedScene];
|
||||||
|
if (stat) return { impressions: stat.impressions, revenue_yuan: stat.revenue_yuan };
|
||||||
const matched = report.items.filter((it) => it.feed_scene === feedScene);
|
const matched = report.items.filter((it) => it.feed_scene === feedScene);
|
||||||
if (matched.length === 0) return null;
|
if (matched.length === 0) return null;
|
||||||
return {
|
return {
|
||||||
@@ -521,6 +526,23 @@ export default function DashboardPage() {
|
|||||||
periodData?.comparison.average_duration_ms,
|
periodData?.comparison.average_duration_ms,
|
||||||
previousPeriodData?.comparison.average_duration_ms,
|
previousPeriodData?.comparison.average_duration_ms,
|
||||||
);
|
);
|
||||||
|
// 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--')
|
||||||
|
const couponPeriod = periodData?.coupon ?? null;
|
||||||
|
const previousCouponPeriod = previousPeriodData?.coupon ?? null;
|
||||||
|
const couponStartedDelta = percentDelta(couponPeriod?.started, previousCouponPeriod?.started);
|
||||||
|
const couponSuccessRateValue =
|
||||||
|
couponPeriod?.success_rate == null ? '--' : (couponPeriod.success_rate * 100).toFixed(1);
|
||||||
|
const couponSuccessRateDelta = pointDelta(couponPeriod?.success_rate, previousCouponPeriod?.success_rate);
|
||||||
|
const couponPointRateValue =
|
||||||
|
couponPeriod?.point_success_rate == null ? '--' : (couponPeriod.point_success_rate * 100).toFixed(1);
|
||||||
|
const couponPointRateDelta = pointDelta(
|
||||||
|
couponPeriod?.point_success_rate,
|
||||||
|
previousCouponPeriod?.point_success_rate,
|
||||||
|
);
|
||||||
|
const couponMedianDelta = durationDelta(
|
||||||
|
couponPeriod?.median_elapsed_ms,
|
||||||
|
previousCouponPeriod?.median_elapsed_ms,
|
||||||
|
);
|
||||||
const averageSavedDelta = moneyDeltaCents(
|
const averageSavedDelta = moneyDeltaCents(
|
||||||
periodData?.comparison.average_saved_cents,
|
periodData?.comparison.average_saved_cents,
|
||||||
previousPeriodData?.comparison.average_saved_cents,
|
previousPeriodData?.comparison.average_saved_cents,
|
||||||
@@ -761,6 +783,51 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-head">
|
||||||
|
<span className="bar" />
|
||||||
|
<h3>领券核心数据</h3>
|
||||||
|
<span>发起、整场成功、点位成功与效率</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid g4">
|
||||||
|
<StatCard
|
||||||
|
title="领券发起数"
|
||||||
|
value={fmtInt(couponPeriod?.started)}
|
||||||
|
unit="次"
|
||||||
|
delta={couponStartedDelta.value}
|
||||||
|
deltaTone={couponStartedDelta.tone}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="领券成功率"
|
||||||
|
value={couponSuccessRateValue}
|
||||||
|
unit="%"
|
||||||
|
delta={couponSuccessRateDelta.value}
|
||||||
|
deltaTone={couponSuccessRateDelta.tone}
|
||||||
|
hint={`全部点位领成功的次数 / 领券发起数;本期 ${fmtInt(couponPeriod?.all_success)} / ${fmtInt(
|
||||||
|
couponPeriod?.started,
|
||||||
|
)} 次。点位成功口径含「今日已领过」。`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="点位成功率"
|
||||||
|
value={couponPointRateValue}
|
||||||
|
unit="%"
|
||||||
|
delta={couponPointRateDelta.value}
|
||||||
|
deltaTone={couponPointRateDelta.tone}
|
||||||
|
hint={`成功点位数 / (领券发起数 × 每次应领点位数);中途退出未跑到的点位计入分母视为失败。本期成功点位 ${fmtInt(
|
||||||
|
couponPeriod?.point_success,
|
||||||
|
)} 个,每次应领 ${fmtInt(couponPeriod?.points_per_session)} 个(按本期完成场自动校准)。`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="耗时中位数"
|
||||||
|
value={fmtMsAsSeconds(couponPeriod?.median_elapsed_ms)}
|
||||||
|
unit="秒"
|
||||||
|
delta={couponMedianDelta.value}
|
||||||
|
deltaTone={couponMedianDelta.tone}
|
||||||
|
hint="仅统计「完成」的领券(同领券数据页口径),中位数比均值更抗中途暂停等待的长尾。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<span className="bar" />
|
<span className="bar" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,21 @@ import type { Key } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { SorterResult } from 'antd/es/table/interface';
|
import type { SorterResult } from 'antd/es/table/interface';
|
||||||
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
|
import { App, Button, DatePicker, Input, Select, Space, Table, Tag, Tooltip } from 'antd';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
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;
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
|
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
|
||||||
|
|
||||||
type SortField = 'id' | 'created_at' | 'last_login_at';
|
type SortField = 'id' | 'created_at' | 'last_active_at';
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -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[]>([]);
|
||||||
|
|
||||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||||
@@ -61,8 +64,8 @@ export default function UsersPage() {
|
|||||||
register_channel: channel || undefined,
|
register_channel: channel || undefined,
|
||||||
created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined,
|
created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined,
|
||||||
created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined,
|
created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined,
|
||||||
last_login_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
|
last_active_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
|
||||||
last_login_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
|
last_active_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@@ -235,18 +238,25 @@ export default function UsersPage() {
|
|||||||
render: (v: string) => formatUtcTime(v),
|
render: (v: string) => formatUtcTime(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '最近登录',
|
// 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券),与大盘 DAU/留存活跃口径一致(2026-07-05)
|
||||||
dataIndex: 'last_login_at',
|
title: (
|
||||||
|
<Tooltip title="进入 App(登录)/ 发起比价 / 发起领券,取最近一次">
|
||||||
|
最近活跃
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'last_active_at',
|
||||||
width: 160,
|
width: 160,
|
||||||
sorter: true,
|
sorter: true,
|
||||||
sortOrder: sortOrderOf('last_login_at'),
|
// 关掉 antd 默认的「点击升序/降序」表头提示,否则与上面自定义口径 Tooltip 同时弹出互相遮挡
|
||||||
render: (v: string) => formatUtcTime(v),
|
showSorterTooltip: false,
|
||||||
|
sortOrder: sortOrderOf('last_active_at'),
|
||||||
|
render: (v: string | null, u: UserListItem) => formatUtcTime(v ?? u.last_login_at),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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>
|
||||||
@@ -269,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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -324,7 +339,7 @@ export default function UsersPage() {
|
|||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
<RangePicker
|
<RangePicker
|
||||||
placeholder={['最近登录起', '最近登录止']}
|
placeholder={['最近活跃起', '最近活跃止']}
|
||||||
value={loginRange}
|
value={loginRange}
|
||||||
onChange={(v) => setLoginRange(v as [Dayjs, Dayjs] | null)}
|
onChange={(v) => setLoginRange(v as [Dayjs, Dayjs] | null)}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -377,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -624,12 +624,27 @@ export default function WithdrawsPage() {
|
|||||||
const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0);
|
const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0);
|
||||||
const ledgerIssues = ledger
|
const ledgerIssues = ledger
|
||||||
? [
|
? [
|
||||||
ledger.balance_diff_cents ? `余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
|
// 普通现金账(coin_cash)
|
||||||
ledger.missing_withdraw_txn_count ? `缺扣款流水 ${ledger.missing_withdraw_txn_count} 笔` : '',
|
ledger.balance_diff_cents ? `现金余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
|
||||||
ledger.missing_refund_txn_count ? `缺退款流水 ${ledger.missing_refund_txn_count} 笔` : '',
|
ledger.missing_withdraw_txn_count ? `现金缺扣款流水 ${ledger.missing_withdraw_txn_count} 笔` : '',
|
||||||
ledger.duplicate_refund_txn_count ? `重复退款流水 ${ledger.duplicate_refund_txn_count} 类` : '',
|
ledger.missing_refund_txn_count ? `现金缺退款流水 ${ledger.missing_refund_txn_count} 笔` : '',
|
||||||
|
ledger.duplicate_refund_txn_count ? `现金重复退款流水 ${ledger.duplicate_refund_txn_count} 类` : '',
|
||||||
ledger.refund_txn_on_non_terminal_count
|
ledger.refund_txn_on_non_terminal_count
|
||||||
? `非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count} 笔`
|
? `现金非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count} 笔`
|
||||||
|
: '',
|
||||||
|
// 邀请奖励金账(invite_cash)
|
||||||
|
ledger.invite_balance_diff_cents ? `邀请金余额差额 ${yuan(ledger.invite_balance_diff_cents)}` : '',
|
||||||
|
ledger.invite_missing_withdraw_txn_count
|
||||||
|
? `邀请金缺扣款流水 ${ledger.invite_missing_withdraw_txn_count} 笔`
|
||||||
|
: '',
|
||||||
|
ledger.invite_missing_refund_txn_count
|
||||||
|
? `邀请金缺退款流水 ${ledger.invite_missing_refund_txn_count} 笔`
|
||||||
|
: '',
|
||||||
|
ledger.invite_duplicate_refund_txn_count
|
||||||
|
? `邀请金重复退款流水 ${ledger.invite_duplicate_refund_txn_count} 类`
|
||||||
|
: '',
|
||||||
|
ledger.invite_refund_txn_on_non_terminal_count
|
||||||
|
? `邀请金非退款终态存在退款流水 ${ledger.invite_refund_txn_on_non_terminal_count} 笔`
|
||||||
: '',
|
: '',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
@@ -669,7 +684,7 @@ export default function WithdrawsPage() {
|
|||||||
message={ledger.ok ? '现金账本校验正常' : '现金账本存在异常'}
|
message={ledger.ok ? '现金账本校验正常' : '现金账本存在异常'}
|
||||||
description={
|
description={
|
||||||
ledger.ok
|
ledger.ok
|
||||||
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
|
? `现金 余额 ${yuan(ledger.cash_balance_total_cents)} / 流水 ${yuan(ledger.cash_transaction_total_cents)};邀请金 余额 ${yuan(ledger.invite_cash_balance_total_cents)} / 流水 ${yuan(ledger.invite_cash_transaction_total_cents)}`
|
||||||
: ledgerIssues.join(';')
|
: ledgerIssues.join(';')
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+32
-3
@@ -3,11 +3,12 @@
|
|||||||
export interface AdminInfo {
|
export interface AdminInfo {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
role: string; // super_admin / finance / operator / 自定义角色名
|
role: string; // super_admin / finance / operator / custom(自定义) / 自定义角色名
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login_at: string | null;
|
last_login_at: string | null;
|
||||||
pages?: string[]; // 当前角色有效可见页 key(登录 / /me 下发);super_admin = 全部页。左侧导航按此过滤
|
pages?: string[]; // 当前有效可见页 key(登录 / /me 下发);super_admin = 全部页;role==custom = pages_override。左侧导航按此过滤
|
||||||
|
pages_override?: string[] | null; // 「自定义」勾选的可见页原始集(role==custom 时非空);列表接口下发,供编辑回填勾选
|
||||||
password?: string | null; // 明文登录密码(仅账号列表下发);无留存(脚本建的超管)为 null → 不显示
|
password?: string | null; // 明文登录密码(仅账号列表下发);无留存(脚本建的超管)为 null → 不显示
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,8 @@ export interface UserListItem {
|
|||||||
wechat_openid: string | null;
|
wechat_openid: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login_at: string;
|
last_login_at: string;
|
||||||
|
// 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);与大盘 DAU/留存活跃口径一致
|
||||||
|
last_active_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。
|
// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。
|
||||||
@@ -231,6 +234,7 @@ export interface WithdrawBulkResult {
|
|||||||
|
|
||||||
export interface WithdrawLedgerCheck {
|
export interface WithdrawLedgerCheck {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
// 普通现金账(coin_cash)
|
||||||
cash_balance_total_cents: number;
|
cash_balance_total_cents: number;
|
||||||
cash_transaction_total_cents: number;
|
cash_transaction_total_cents: number;
|
||||||
balance_diff_cents: number;
|
balance_diff_cents: number;
|
||||||
@@ -238,6 +242,14 @@ export interface WithdrawLedgerCheck {
|
|||||||
missing_refund_txn_count: number;
|
missing_refund_txn_count: number;
|
||||||
duplicate_refund_txn_count: number;
|
duplicate_refund_txn_count: number;
|
||||||
refund_txn_on_non_terminal_count: number;
|
refund_txn_on_non_terminal_count: number;
|
||||||
|
// 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
||||||
|
invite_cash_balance_total_cents: number;
|
||||||
|
invite_cash_transaction_total_cents: number;
|
||||||
|
invite_balance_diff_cents: number;
|
||||||
|
invite_missing_withdraw_txn_count: number;
|
||||||
|
invite_missing_refund_txn_count: number;
|
||||||
|
invite_duplicate_refund_txn_count: number;
|
||||||
|
invite_refund_txn_on_non_terminal_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WxpayHealthCheck {
|
export interface WxpayHealthCheck {
|
||||||
@@ -474,13 +486,17 @@ export interface AdRevenueReport {
|
|||||||
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
||||||
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
|
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
|
||||||
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
|
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
|
||||||
|
// 按信息流场景小计(comparison/coupon/welfare;全量,不受分页截断)。数据大盘「领券广告/比价广告」卡
|
||||||
|
// 优先读它:2026-07-02 后端起 items 不再含信息流逐条展示行(唯一带收益+场景的行),按 items 现算恒 0。
|
||||||
|
// 可选(`?.` 探测)以兼容未升级后端,缺失时回退 items 现算。
|
||||||
|
scene_stats?: Record<string, AdRevenueTypeStat>;
|
||||||
// 可选:后端预聚合的「按广告类型」富小计(含发放金币明细)。当前后端未下发 → 数据大盘页
|
// 可选:后端预聚合的「按广告类型」富小计(含发放金币明细)。当前后端未下发 → 数据大盘页
|
||||||
// (dashboard adTypeSummary)用 `?.` 探测、缺失时按 items 现算兜底;后端补上即自动启用。
|
// (dashboard adTypeSummary)用 `?.` 探测、缺失时按 items 现算兜底;后端补上即自动启用。
|
||||||
by_ad_type?: Record<
|
by_ad_type?: Record<
|
||||||
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;
|
||||||
@@ -529,6 +545,8 @@ export interface DashboardOverview {
|
|||||||
new: number;
|
new: number;
|
||||||
active: number;
|
active: number;
|
||||||
retained_new_users: number;
|
retained_new_users: number;
|
||||||
|
// 次日留存基数:窗口内逐日「前一日新增用户数」之和(retention_rate 的分母;旧后端无此字段)
|
||||||
|
retention_cohort?: number;
|
||||||
retention_rate: number | null;
|
retention_rate: number | null;
|
||||||
retention_note: string;
|
retention_note: string;
|
||||||
};
|
};
|
||||||
@@ -540,6 +558,17 @@ export interface DashboardOverview {
|
|||||||
average_duration_ms: number | null;
|
average_duration_ms: number | null;
|
||||||
average_saved_cents: number | null;
|
average_saved_cents: number | null;
|
||||||
};
|
};
|
||||||
|
// 领券核心数据(2026-07-05 新增;旧后端无此字段,前端 `?.` 探测)。
|
||||||
|
// 点位=一张券;成功口径 success+already_claimed;点位成功率分母=发起数×应领点位数(未跑到视为失败)。
|
||||||
|
coupon?: {
|
||||||
|
started: number;
|
||||||
|
all_success: number; // 全部点位领成功的完成场次数
|
||||||
|
success_rate: number | null;
|
||||||
|
point_success: number;
|
||||||
|
points_per_session: number | null; // 应领点位数(本期完成场点位数众数)
|
||||||
|
point_success_rate: number | null;
|
||||||
|
median_elapsed_ms: number | null; // 仅 completed,同「领券数据」页口径
|
||||||
|
};
|
||||||
coins: {
|
coins: {
|
||||||
granted_total: number;
|
granted_total: number;
|
||||||
reward_video_coin_total: number;
|
reward_video_coin_total: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user