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
7 changed files with 329 additions and 85 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>
);
}
+73 -6
View File
@@ -118,9 +118,10 @@ function previousPeriodRange(range: ReturnType<typeof periodRange>) {
}
function retentionTitle(period: PeriodKey) {
if (period === '7d') return '近 7 日新增留存';
if (period === '30d') return '近 30 日新增留存';
return '昨日新增留存';
// 次日留存口径(2026-07-05):每天取前一日新增用户,统计其当日活跃比例;单日窗口即「前日新增的昨日留存」。
if (period === '7d') return '近 7 日新增次日留存';
if (period === '30d') return '近 30 日新增次日留存';
return '前日用户新增留存';
}
function avgEcpm(report: AdRevenueReport | null) {
@@ -131,19 +132,23 @@ function avgEcpm(report: AdRevenueReport | null) {
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
if (!report) return null;
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);
if (matched.length === 0) return null;
return {
ad_type: adType,
impressions: matched.reduce((sum, it) => sum + it.impressions, 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') {
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);
if (matched.length === 0) return null;
return {
@@ -521,6 +526,23 @@ export default function DashboardPage() {
periodData?.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(
periodData?.comparison.average_saved_cents,
previousPeriodData?.comparison.average_saved_cents,
@@ -761,6 +783,51 @@ export default function DashboardPage() {
</div>
</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">
<div className="section-head">
<span className="bar" />
+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>
);
}
+32 -11
View File
@@ -5,20 +5,21 @@ 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 { App, Button, DatePicker, Input, Select, Space, Table, Tag, Tooltip } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
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;
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() {
const router = useRouter();
@@ -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[]>([]);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
@@ -61,8 +64,8 @@ export default function UsersPage() {
register_channel: channel || undefined,
created_from: regRange?.[0] ? regRange[0].startOf('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_login_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
last_active_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
last_active_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
@@ -235,18 +238,25 @@ export default function UsersPage() {
render: (v: string) => formatUtcTime(v),
},
{
title: '最近登录',
dataIndex: 'last_login_at',
// 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券),与大盘 DAU/留存活跃口径一致(2026-07-05)
title: (
<Tooltip title="进入 App(登录)/ 发起比价 / 发起领券,取最近一次">
</Tooltip>
),
dataIndex: 'last_active_at',
width: 160,
sorter: true,
sortOrder: sortOrderOf('last_login_at'),
render: (v: string) => formatUtcTime(v),
// 关掉 antd 默认的「点击升序/降序」表头提示,否则与上面自定义口径 Tooltip 同时弹出互相遮挡
showSorterTooltip: false,
sortOrder: sortOrderOf('last_active_at'),
render: (v: string | null, u: UserListItem) => formatUtcTime(v ?? u.last_login_at),
},
{
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>
@@ -269,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>
),
},
@@ -324,7 +339,7 @@ export default function UsersPage() {
allowClear
/>
<RangePicker
placeholder={['最近登录起', '最近登录止']}
placeholder={['最近活跃起', '最近活跃止']}
value={loginRange}
onChange={(v) => setLoginRange(v as [Dayjs, Dayjs] | null)}
allowClear
@@ -377,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>
);
}
+20 -1
View File
@@ -56,6 +56,8 @@ export interface UserListItem {
wechat_openid: string | null;
created_at: string;
last_login_at: string;
// 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);与大盘 DAU/留存活跃口径一致
last_active_at: string | null;
}
// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。
@@ -474,13 +476,17 @@ export interface AdRevenueReport {
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
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 现算兜底;后端补上即自动启用。
by_ad_type?: Record<
string,
{ ad_type: string; impressions: number; revenue_yuan: number; expected_coin: number; actual_coin: number }
>;
dau: number | null; // 今日活跃(复用大盘 last_login_at);查询=今日时有值,否则 null
dau: number | null; // 区间去重活跃用户(口径同数据大盘 period.users.active:登录+开始比价+开始领券);查询区间统计、含今日,全局口径不随筛选变化
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
total_impressions: number;
@@ -529,6 +535,8 @@ export interface DashboardOverview {
new: number;
active: number;
retained_new_users: number;
// 次日留存基数:窗口内逐日「前一日新增用户数」之和(retention_rate 的分母;旧后端无此字段)
retention_cohort?: number;
retention_rate: number | null;
retention_note: string;
};
@@ -540,6 +548,17 @@ export interface DashboardOverview {
average_duration_ms: 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: {
granted_total: number;
reward_video_coin_total: number;