Compare commits

..

5 Commits

Author SHA1 Message Date
zzhyyyyy ffad461d67 feat(marquee): 预览改为「按模式分页浏览全部可展示记录」
在原内联预览基础上,把「换一批」随机预览改为分页浏览:
- 上一页/下一页翻遍当前模式下 app 能露出的全部记录(不去重),不再只随机抽一批。
- 跟随数据源模式:只真实=真实记录、只种子=各种子生成、混播=真实+种子;切模式回第 1 页。
- 种子行标「种子」、真实行标 u{id};配套 app-server /marquee-seeds/real-records 接口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:09:44 +08:00
zzhyyyyy f950bcd841 feat(marquee): 首页轮播种子预览改内联常驻、随数据源模式实时刷新
系统配置「首页轮播种子」页:预览从点按钮弹窗改为常驻在「数据源」开关下方,
切换「混播/只真实/只种子」即时重拉、所见即所得。

- 预览请求带上当前选中的 mode(配套 app-server 预览接口的 mode 入参),不依赖 PATCH
  落库时序;拨动开关(乐观更新)即刻预览对应模式,切换失败回滚预览也随之回滚。
- 移除原「预览效果」弹窗及相关状态;「只真实」无数据时给空态提示。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:01:20 +08:00
liujiahui b1bdb66a76 feat(users): 用户管理加注销账号入口(super_admin) (#35)
## 用户管理:加注销账号入口(super_admin)

配合后端注销级联清理 + 删除端点(app-server PR #115),后台加删除入口。

### 改动

- **新增 `DeleteUserModal`**:受控弹窗,必填「注销原因(入审计)」,红字说明删什么/保留什么;`api.delete` 带 body 调 `DELETE /admin/api/users/{id}`;后端 `409`(有未提现现金/邀请金 / 在审提现单)经 `errMsg` 原样透出。列表页与详情页共用一套(同 `AdjustBalanceModals`)。
- **用户详情页**:顶部加红色「注销账号」按钮(`canDo(['super_admin'])`,`deleted` 状态隐藏)。
- **用户列表页**:操作列加红色「注销」内联链接(同门控),列宽 300→340。

### 门控

前端 `canDo(['super_admin'])` 与后端 `require_role("super_admin")` 对齐;门控被绕过后端亦兜底(403)。

### 验证

`next build` ✓ Compiled successfully(tsc + eslint + 21 路由静态生成)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #35
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-06 00:25:47 +08:00
chenshirui c905e15ec9 大盘领券和比价广告收益卡改读服务端全量场景小计,激励视频卡改读类型小计 (#37)
Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #37
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-07-06 00:24:58 +08:00
zhuzihao dd84d771f9 feat(ad-revenue): 收益报表 DAU/ARPU 随所选区间显示(今日/近7天/近30天) (#38)
- DAU/ARPU 不再写死「今日」:随查询区间显示——今日仍为「今日活跃 DAU / ARPU(今日)」,
  多天为「活跃用户 / ARPU」;tooltip 标出统计区间与口径(同数据大盘:登录 + 开始比价 +
  开始领券,全局去重、不随筛选变化)。
- 「核心指标」分隔线追加「· 统计区间 X」;更新 dau 类型注释与 ARPU 派生注释。
- 依赖后端把 dau 改为按区间统计(见 app-server 对应 PR):原多天返回 null 显示「-」,
  现在近 7 天 / 近 30 天有值。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #38
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-06 00:23:26 +08:00
6 changed files with 221 additions and 70 deletions
+23 -7
View File
@@ -490,7 +490,7 @@ export default function AdRevenueReportPage() {
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
// 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 预估收益÷今日DAU(仅今日)。
// 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 区间预估收益÷区间活跃用户(见 dau)。
const derived = data
? {
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
: null,
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,
}
: 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。
const drawStat = data?.type_stats?.draw;
const rvStat = data?.type_stats?.reward_video;
@@ -657,7 +669,7 @@ export default function AdRevenueReportPage() {
<Card size="small" style={{ marginBottom: 16 }}>
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{dauRangeLabel ? ` · 统计区间 ${dauRangeLabel}` : ''}
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
@@ -667,9 +679,11 @@ export default function AdRevenueReportPage() {
<Col flex="1 1 0">
<Statistic
title={
<Tooltip title="每活跃用户广告预估收入 = 预估收益 ÷ 今日 DAU;DAU 仅今日口径,历史/多天显示 -">
<Tooltip
title={`每活跃用户广告预估收入 = 区间预估收益 ÷ 区间去重活跃用户(口径同数据大盘)。统计区间:${dauRangeLabel || '—'}`}
>
<span>
ARPU()
{dauRangeIsToday ? 'ARPU(今日)' : 'ARPU'}
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</span>
</Tooltip>
@@ -681,9 +695,11 @@ export default function AdRevenueReportPage() {
<Col flex="1 1 0">
<Statistic
title={
<Tooltip title="今日活跃用户(复用大盘口径 last_login_at = 今日登录过);仅查询=今日时有值,历史/多天显示 -">
<Tooltip
title={`区间去重活跃用户,口径同数据大盘:登录 + 开始比价 + 开始领券,按用户去重。全局口径,不随用户/类型/场景/应用筛选变化。统计区间:${dauRangeLabel || '—'}`}
>
<span>
DAU
{dauRangeIsToday ? '今日活跃 DAU' : '活跃用户'}
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</span>
</Tooltip>
+106 -60
View File
@@ -42,13 +42,15 @@ interface BulkVals {
enabled: boolean;
}
interface PreviewItem {
interface RealRecord {
masked_user: string;
saved_amount_cents: number;
time: string;
created_at: string;
user_id: number;
}
const yuan = (cents: number) => (cents / 100).toFixed(2);
const MODE_LABEL: Record<string, string> = { mixed: '混播', real: '只真实', seed: '只种子' };
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「系统配置 / 首页」里的一个区块。 */
export default function HomeMarqueeSeeds() {
@@ -66,9 +68,12 @@ export default function HomeMarqueeSeeds() {
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkForm] = Form.useForm<BulkVals>();
const [previewOpen, setPreviewOpen] = useState(false);
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
// 「可展示的真实记录」分页浏览(替代原随机预览):翻遍所有 success+省>0 的真实记录
const REC_PAGE_SIZE = 8;
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 () => {
setLoading(true);
@@ -94,6 +99,7 @@ export default function HomeMarqueeSeeds() {
const changeMode = async (m: string) => {
const prev = mode;
setMode(m);
setRecPage(0); // 切模式回第 1 页(不同模式条数不同,避免停在越界页)
setModeSaving(true);
try {
await api.patch('/admin/api/marquee-seeds/mode', { mode: m });
@@ -168,22 +174,29 @@ export default function HomeMarqueeSeeds() {
}
};
// ===== 预览实际 feed =====
const fetchPreview = async () => {
setPreviewLoading(true);
// ===== 可展示的记录:按「当前模式」分页浏览全部(不去重)=====
// 只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,
// 后端固定种子 → 翻页稳定、能翻遍全部。
const fetchRecords = async (page: number, m: string) => {
setRecLoading(true);
try {
const { data } = await api.get<{ items: PreviewItem[] }>('/admin/api/marquee-seeds/preview?limit=8');
setPreviewItems(data.items);
const { data } = await api.get<{ items: RealRecord[]; total: number }>(
'/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) {
message.error(errMsg(e));
} finally {
setPreviewLoading(false);
setRecLoading(false);
}
};
const openPreview = () => {
setPreviewOpen(true);
fetchPreview();
};
// 页码或模式变化都重拉(切模式时 changeMode 会把页码重置到 0)。
useEffect(() => {
fetchRecords(recPage, mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recPage, mode]);
const toggle = async (s: Seed, enabled: boolean) => {
try {
@@ -300,6 +313,84 @@ export default function HomeMarqueeSeeds() {
=+;=();=/()
</span>
</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>
<Button type="primary" size="small" onClick={openAdd}>
@@ -307,9 +398,6 @@ export default function HomeMarqueeSeeds() {
<Button size="small" onClick={openBulk}>
</Button>
<Button size="small" onClick={openPreview}>
</Button>
<span style={{ borderLeft: '1px solid #eee', height: 18 }} />
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(true)}>
@@ -413,48 +501,6 @@ export default function HomeMarqueeSeeds() {
</Form.Item>
</Form>
</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>
);
}
+14
View File
@@ -23,6 +23,7 @@ import { canDo } from '@/lib/auth';
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import { DeleteUserModal } from '@/components/DeleteUserModal';
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
export default function UserDetailPage() {
@@ -33,6 +34,7 @@ export default function UserDetailPage() {
const [err, setErr] = useState<string | null>(null);
const [coinOpen, setCoinOpen] = useState(false);
const [cashOpen, setCashOpen] = useState(false);
const [delOpen, setDelOpen] = useState(false);
const loadData = useCallback(() => {
setErr(null);
@@ -52,6 +54,7 @@ export default function UserDetailPage() {
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
const toggleStatus = () => {
if (!data) return;
@@ -154,6 +157,11 @@ export default function UserDetailPage() {
{data.user.status === 'active' ? '封禁' : '解封'}
</Button>
)}
{canDelete && data.user.status !== 'deleted' && (
<Button danger onClick={() => setDelOpen(true)}>
</Button>
)}
</Space>
</Space>
<Card style={{ marginBottom: 16 }}>
@@ -272,6 +280,12 @@ export default function UserDetailPage() {
cash.reload();
}}
/>
<DeleteUserModal
user={data.user}
open={delOpen}
onClose={() => setDelOpen(false)}
onDone={loadData}
/>
</div>
);
}
+16 -2
View File
@@ -12,6 +12,7 @@ import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import { DeleteUserModal } from '@/components/DeleteUserModal';
import type { UserListItem } from '@/lib/types';
const { RangePicker } = DatePicker;
@@ -43,9 +44,11 @@ export default function UsersPage() {
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
const [delUser, setDelUser] = useState<UserListItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
@@ -253,7 +256,7 @@ export default function UsersPage() {
title: '操作',
key: 'op',
fixed: 'right',
width: 300,
width: 340,
render: (_: unknown, u: UserListItem) => (
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
<a onClick={() => router.push(`/users/${u.id}`)}></a>
@@ -276,6 +279,11 @@ export default function UsersPage() {
) : (
<a onClick={() => setForceOnboarding(u, true)}></a>
))}
{canDelete && u.status !== 'deleted' && (
<a style={{ color: '#cf1322' }} onClick={() => setDelUser(u)}>
</a>
)}
</Space>
),
},
@@ -384,12 +392,18 @@ export default function UsersPage() {
showTotal: (t) => `${t} 个用户`,
onChange: onPageChange,
}}
scroll={{ x: 1160 }}
scroll={{ x: 1200 }}
onChange={onTableChange}
/>
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
<DeleteUserModal
user={delUser}
open={!!delUser}
onClose={() => setDelUser(null)}
onDone={reload}
/>
</div>
);
}
+61
View File
@@ -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
View File
@@ -486,7 +486,7 @@ export interface AdRevenueReport {
string,
{ 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; // 当前筛选下的分页总条数(全量,不受分页影响)
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
total_impressions: number;