Compare commits

...

5 Commits

Author SHA1 Message Date
guke 59d2586e41 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web into feat/analytics-health-dashboard 2026-07-09 10:46:05 +08:00
guke 3a2aa584a3 feat(analytics-health): 新增埋点/上报成功率看板(super_admin)
- 页面 analytics-health/page.tsx:总览卡(埋点/上报两大率+四原子量)、按天双折线趋势
  (@ant-design/plots v2,dynamic ssr:false)、event/app_ver/oem 下钻表;含刷新+
  「数据更新于」时间戳、近7/30天区间预设、率<90%标红、四指标 tooltip。
- 类型 types.ts:HealthMetrics / HealthTrendPoint / HealthBreakdownRow。
- 导航 layout.tsx:「看板」组挂 /analytics-health(仅 super_admin 可见,后端权限零改动)。
- 数据来自后端 feat/analytics-success-rate 分支的 /admin/api/analytics-health/* 三个只读端点;
  区间按北京日转 UTC 右开区间传参。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:43:59 +08:00
liujiahui 557348bd6b 权限管理新增人员支持「自定义」角色(前端) (#41)
角色下拉末尾加「自定义」,选中时「可见页面」从只读矩阵切成可勾选(复用 PermEditor),提交带 pages_override;
编辑自定义用户回填已勾选页;列表里 custom 显示「自定义」,列表内切到自定义转去编辑页勾选。
AdminInfo 加 pages_override 字段。npm run build 通过。

配套后端 PR:shaguabijia-app-server#admin-custom-perms。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #41
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-09 01:11:29 +08:00
zhuzihao bc677817e4 feat(marquee): 首页轮播种子预览改内联常驻、随数据源模式实时刷新 (#40)
系统配置「首页轮播种子」页:预览从点按钮弹窗改为常驻在「数据源」开关下方,
切换「混播/只真实/只种子」即时重拉、所见即所得。

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

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #40
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-08 00:57:11 +08:00
zhuzihao 33c80d6927 fix(withdraws): 提现账本校验区分现金/邀请金两本账展示 (#39)
配合后端 ledger-check 分账改造:
- 异常清单按「现金」「邀请金」分别标注(缺扣款/缺退款/重复退款/
  非退款终态退款/余额差额),便于定位问题落在哪本账。
- 绿色(校验正常)时分别展示两账的余额与流水净额。
- WithdrawLedgerCheck 类型同步后端新增的 7 个 invite_* 字段。

修复现象:此前邀请奖励金提现单被误判为「缺扣款/缺退款流水」,
导致提现审核页现金账本报红。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #39
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-07 00:16:25 +08:00
6 changed files with 564 additions and 78 deletions
+49 -10
View File
@@ -22,6 +22,10 @@ function genPassword(len = 10): string {
type View = 'list' | 'person' | 'roles' | 'roleForm';
// 「自定义」哨兵角色:不是共享角色,选它时可见页由本人逐页勾选(存 pages_override),与后端一致。
const CUSTOM_ROLE = 'custom';
const CUSTOM_LABEL = '自定义';
// ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)=====
function PermMatrix({ catalog, pages }: { catalog: PermissionGroup[]; pages: string[] }) {
const has = useMemo(() => new Set(pages), [pages]);
@@ -109,6 +113,11 @@ export default function AdminsPage() {
return m;
}, [roles]);
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 () => {
setLoading(true);
@@ -134,11 +143,14 @@ export default function AdminsPage() {
const [personName, setPersonName] = useState('');
const [personRole, setPersonRole] = useState('');
const [initPw, setInitPw] = useState('');
// 「自定义」角色时逐页勾选的可见页集(role==custom 才用到)
const [personPages, setPersonPages] = useState<string[]>([]);
const openCreatePerson = () => {
setPersonEditing(null);
setPersonName('');
setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator');
setPersonPages([]);
setInitPw(genPassword());
setView('person');
};
@@ -146,6 +158,8 @@ export default function AdminsPage() {
setPersonEditing(a);
setPersonName(a.username);
setPersonRole(a.role);
// 编辑「自定义」用户时,回填其已勾选的可见页(供继续增删)
setPersonPages(a.role === CUSTOM_ROLE ? (a.pages_override ?? []) : []);
setInitPw(''); // 编辑态密码只读展示 a.password(已确定的登录密码),不用 initPw
setView('person');
};
@@ -160,18 +174,30 @@ export default function AdminsPage() {
<Button onClick={() => copyText(value)}></Button>
</div>
);
const isCustom = personRole === CUSTOM_ROLE;
const submitPerson = async () => {
if (isCustom && personPages.length === 0) { message.error('自定义角色请至少勾选一个可见页面'); return; }
try {
if (personEditing) {
// 密码只读、不改;提交变更的角色
if (personRole !== personEditing.role) {
await api.patch(`/admin/api/admins/${personEditing.id}`, { role: personRole });
// 密码只读、不改;提交变更的角色 +(自定义时)勾选的可见页
const roleChanged = personRole !== personEditing.role;
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('已保存');
} else {
if (personName.trim().length < 3) { message.error('用户名至少 3 位'); return; }
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('已创建,请把初始密码转交本人');
}
@@ -184,8 +210,10 @@ export default function AdminsPage() {
// ===== 列表操作 =====
const changeRole = (a: AdminInfo, role: string) => {
if (role === a.role) return;
// 改成「自定义」需要逐页勾选 → 转去编辑页(列表里没有页选择器,直接切会得到空可见页)
if (role === CUSTOM_ROLE) { openEditPerson({ ...a, role: CUSTOM_ROLE }); return; }
modal.confirm({
title: `${a.username} 的角色改为 ${role}?`,
title: `${a.username} 的角色改为 ${roleLabelOf(role)}?`,
onOk: async () => {
try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); }
catch (e) { message.error(errMsg(e)); }
@@ -266,8 +294,9 @@ export default function AdminsPage() {
{ title: '用户名', dataIndex: 'username' },
{
title: '角色', dataIndex: 'role', width: 170,
// options 含「自定义」→ custom 用户在列表里显示为「自定义」;选它会转去编辑页勾选(见 changeRole)
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> },
@@ -341,16 +370,26 @@ export default function AdminsPage() {
<span><span style={{ color: '#cf1322' }}> *</span></span>
{isSuper && <a onClick={openRoles}> </a>}
</div>
<Select value={personRole} options={roleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>;</div>
<Select value={personRole} options={personRoleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>
{isCustom
? '「自定义」:下方逐页勾选该成员可见的页面,仅对本人生效(不影响任何角色)'
: '可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色'}
</div>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span></span>
<span style={{ color: '#999', fontSize: 12 }}>,(=)</span>
<span style={{ color: '#999', fontSize: 12 }}>
{isCustom ? '勾选本成员可见的页面' : '跟随所选角色,此处只读(深色=可见)'}
</span>
</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 style={{ marginTop: 24, textAlign: 'right' }}>
+350
View File
@@ -0,0 +1,350 @@
'use client';
// 埋点 / 上报成功率大盘:度量客户端埋点数据可信度,两段漏斗两个大率。
// 数据源 analytics_selfstat 快照差分(与埋点日志表无关);三个只读端点在 admin 服务(8771)。
// 时区:端点吃 UTC 时刻(见 toUtcRange);trend.day 已是北京日,直接展示不再换算。
import { useCallback, useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Button,
Card,
Col,
DatePicker,
Empty,
Row,
Segmented,
Space,
Statistic,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { api, errMsg } from '@/lib/api';
import type { HealthBreakdownRow, HealthMetrics, HealthTrendPoint } from '@/lib/types';
dayjs.extend(utc);
dayjs.extend(timezone);
const CN = 'Asia/Shanghai';
// @ant-design/plots 依赖 DOM,关掉 SSR 仅客户端渲染(否则 Next 预渲染报 document undefined)。
// 用仓内 v2 写法(colorField/scale/axis/interaction),非指南里的 v1(seriesField/yAxis)。
const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false });
const { RangePicker } = DatePicker;
type Dim = 'event' | 'app_ver' | 'oem';
const DIM_LABEL: Record<Dim, string> = { event: '事件', app_ver: '版本', oem: 'ROM' };
const LOW = 0.9; // 成功率低于此阈值标红(定位掉数据最狠的点位/版本/ROM)
// 北京日范围 → UTC ISO 瞬时(右开区间;date_to = 结束日 +1 天的北京零点)。
// 端点按 UTC 时刻比较 created_at,直接传 YYYY-MM-DD 会被当 UTC 零点,与北京日差 8 小时。
function toUtcRange(start: Dayjs, end: Dayjs) {
return {
date_from: dayjs.tz(start.format('YYYY-MM-DD'), CN).utc().toISOString(),
date_to: dayjs.tz(end.add(1, 'day').format('YYYY-MM-DD'), CN).utc().toISOString(),
};
}
// 率:null → "-"(分母为 0 ≠ 0%);否则百分比一位小数。
const pct = (v: number | null | undefined) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`);
const int = (v: number | null | undefined) =>
v == null ? '-' : new Intl.NumberFormat('zh-CN').format(v);
// 四原子计数说明(总览卡标题 + 下钻表头 tooltip 共用,一处改两处同步)。
const METRIC_TIP = {
attempted: '端上 track() 被调用的次数,是埋点段分母。埋点成功率 =(attempted drop_capture)/ attempted。',
drop_capture: '采集环节因异常(队列满 / 序列化失败等)被丢弃、未能落地的事件数,计入埋点失败。',
delivered: '成功送达并入库后端的事件数。上报成功率 = delivered /(delivered + drop_undelivered)。',
drop_undelivered:
'已落盘但久未送达、被队列淘汰的事件数(弱网 / 被 ROM 杀后台等);在途未送达的不计入。',
} as const;
// 标题/表头后跟一个说明图标(hover 出 tooltip)。
const withTip = (label: string, tip: string) => (
<Space size={4}>
{label}
<Tooltip title={tip}>
<InfoCircleOutlined style={{ color: '#bfbfbf', cursor: 'help' }} />
</Tooltip>
</Space>
);
// 大率卡:率偏低标红 + 「偏低」标。
function RateStat({
title,
value,
loading,
}: {
title: string;
value: number | null | undefined;
loading: boolean;
}) {
const low = value != null && value < LOW;
return (
<Card loading={loading}>
<Statistic
title={
<Space size={6}>
{title}
{low && <Tag color="error"></Tag>}
</Space>
}
value={value == null ? '-' : (value * 100).toFixed(1)}
suffix={value == null ? undefined : '%'}
valueStyle={low ? { color: '#cf1322' } : undefined}
/>
</Card>
);
}
// 原子量卡(attempted / drop_capture / delivered / drop_undelivered)。
function CountStat({
title,
value,
loading,
warn,
tip,
}: {
title: string;
value: number | null | undefined;
loading: boolean;
warn?: boolean; // drop_* > 0 时橙色提示
tip?: string;
}) {
const bad = warn && value != null && value > 0;
return (
<Card loading={loading}>
<Statistic
title={tip ? withTip(title, tip) : title}
value={value == null ? '-' : value}
valueStyle={bad ? { color: '#d46b08' } : undefined}
/>
</Card>
);
}
export default function AnalyticsHealthPage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([
dayjs().tz(CN).subtract(6, 'day'),
dayjs().tz(CN),
]);
const [dim, setDim] = useState<Dim>('event');
const [overview, setOverview] = useState<HealthMetrics | null>(null);
const [trend, setTrend] = useState<HealthTrendPoint[]>([]);
const [rows, setRows] = useState<HealthBreakdownRow[]>([]);
const [loading, setLoading] = useState(false); // 总览 + 趋势
const [rowsLoading, setRowsLoading] = useState(false); // 下钻表
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
// 总览 + 趋势:只随区间变。
const loadMain = useCallback(async () => {
const params = toUtcRange(range[0], range[1]);
setLoading(true);
try {
const [ov, tr] = await Promise.all([
api.get<HealthMetrics>('/admin/api/analytics-health/overview', { params }),
api.get<HealthTrendPoint[]>('/admin/api/analytics-health/trend', { params }),
]);
setOverview(ov.data);
setTrend(tr.data);
setUpdatedAt(dayjs().tz(CN).format('YYYY-MM-DD HH:mm'));
} catch (e) {
message.error(errMsg(e, '加载成功率数据失败'));
} finally {
setLoading(false);
}
}, [range, message]);
// 下钻表:随区间或维度变(切 dim 只重拉表,不动总览/趋势)。
const loadRows = useCallback(async () => {
const params = toUtcRange(range[0], range[1]);
setRowsLoading(true);
try {
const bd = await api.get<HealthBreakdownRow[]>('/admin/api/analytics-health/breakdown', {
params: { ...params, dim },
});
setRows(bd.data);
} catch (e) {
message.error(errMsg(e, '加载下钻数据失败'));
} finally {
setRowsLoading(false);
}
}, [range, dim, message]);
useEffect(() => {
void loadMain();
}, [loadMain]);
useEffect(() => {
void loadRows();
}, [loadRows]);
const refresh = () => {
void loadMain();
void loadRows();
};
// 折线长表:每天 ×{埋点率, 上报率}。保留 rate=null 的点(不 filter)→ G2 在断点处断线,
// 真正实现「null 点跳过不连线」;若 filter 掉 null 会把两侧点连成一条误导直线。
const trendLong = useMemo(
() =>
trend.flatMap((p) => [
{ day: p.day, metric: '埋点成功率', rate: p.track_success_rate },
{ day: p.day, metric: '上报成功率', rate: p.report_success_rate },
]),
[trend],
);
const chartConfig = {
data: trendLong,
xField: 'day',
yField: 'rate',
colorField: 'metric',
height: 300,
scale: {
y: { domainMin: 0, domainMax: 1, nice: false },
color: { range: ['#1677ff', '#fa8c16'] }, // 埋点率蓝 / 上报率橙(按长表首次出现序)
},
axis: { y: { labelFormatter: (v: number) => `${(v * 100).toFixed(0)}%` } },
point: { style: { r: 3 } },
legend: { color: { position: 'top' as const } },
tooltip: { items: [{ channel: 'y' as const, valueFormatter: (v: number) => pct(v) }] },
interaction: { tooltip: { shared: true } },
};
const columns: ColumnsType<HealthBreakdownRow> = [
{ title: DIM_LABEL[dim], dataIndex: 'key', key: 'key', render: (v: string) => v || '(unknown)' },
{
title: '埋点成功率',
key: 'track',
width: 110,
align: 'right',
render: (_: unknown, r: HealthBreakdownRow) => (
<span style={r.track_success_rate != null && r.track_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
{pct(r.track_success_rate)}
</span>
),
},
{
title: '上报成功率',
key: 'report',
width: 110,
align: 'right',
render: (_: unknown, r: HealthBreakdownRow) => (
<span style={r.report_success_rate != null && r.report_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
{pct(r.report_success_rate)}
</span>
),
},
{ title: withTip('尝试总数', METRIC_TIP.attempted), dataIndex: 'attempted', key: 'attempted', width: 120, align: 'right', render: (v: number) => int(v) },
{ title: withTip('采集丢弃', METRIC_TIP.drop_capture), dataIndex: 'drop_capture', key: 'drop_capture', width: 128, align: 'right', render: (v: number) => int(v) },
{ title: withTip('送达', METRIC_TIP.delivered), dataIndex: 'delivered', key: 'delivered', width: 108, align: 'right', render: (v: number) => int(v) },
{ title: withTip('淘汰未送达', METRIC_TIP.drop_undelivered), dataIndex: 'drop_undelivered', key: 'drop_undelivered', width: 150, align: 'right', render: (v: number) => int(v) },
];
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
<Space align="center">
<h2 style={{ margin: 0 }}></h2>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
/;,
</Typography.Text>
</Space>
<Space align="center">
{updatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{updatedAt}
</Typography.Text>
)}
<Button icon={<ReloadOutlined />} onClick={refresh} loading={loading || rowsLoading}>
</Button>
</Space>
</div>
<Card size="small" style={{ marginBottom: 16 }}>
<Space size={6} align="center">
<Typography.Text type="secondary">()</Typography.Text>
<RangePicker
value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '今天', value: [dayjs().tz(CN), dayjs().tz(CN)] },
{ label: '近 7 天', value: [dayjs().tz(CN).subtract(6, 'day'), dayjs().tz(CN)] },
{ label: '近 30 天', value: [dayjs().tz(CN).subtract(29, 'day'), dayjs().tz(CN)] },
]}
/>
</Space>
</Card>
{/* 总览:两大率居前,四原子量次之 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12}>
<RateStat title="埋点成功率" value={overview?.track_success_rate} loading={loading} />
</Col>
<Col xs={24} sm={12}>
<RateStat title="上报成功率" value={overview?.report_success_rate} loading={loading} />
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<CountStat title="尝试总数 attempted" value={overview?.attempted} loading={loading} tip={METRIC_TIP.attempted} />
</Col>
<Col xs={12} sm={6}>
<CountStat title="采集丢弃 drop_capture" value={overview?.drop_capture} loading={loading} warn tip={METRIC_TIP.drop_capture} />
</Col>
<Col xs={12} sm={6}>
<CountStat title="送达 delivered" value={overview?.delivered} loading={loading} tip={METRIC_TIP.delivered} />
</Col>
<Col xs={12} sm={6}>
<CountStat title="淘汰未送达 drop_undelivered" value={overview?.drop_undelivered} loading={loading} warn tip={METRIC_TIP.drop_undelivered} />
</Col>
</Row>
{/* 趋势:两条率折线,Y 轴锁 [0,1] 显示百分比,null 断线 */}
<Card title="按天趋势(北京时间)" size="small" style={{ marginBottom: 16 }}>
{trendLong.length > 0 ? (
<Line {...chartConfig} />
) : (
<Empty style={{ padding: '60px 0' }} description={loading ? '加载中…' : '该区间暂无埋点数据'} />
)}
</Card>
{/* 下钻:后端已按上报率升序(最差在前),率 < 90% 标红 */}
<Card
title="维度下钻(按上报成功率升序,最差在前)"
size="small"
extra={
<Segmented
value={dim}
onChange={(v) => setDim(v as Dim)}
options={[
{ label: '按事件', value: 'event' },
{ label: '按版本', value: 'app_ver' },
{ label: '按 ROM', value: 'oem' },
]}
/>
}
>
<Table<HealthBreakdownRow>
rowKey="key"
dataSource={rows}
columns={columns}
loading={rowsLoading}
pagination={false}
size="small"
scroll={{ x: 820 }}
locale={{ emptyText: rowsLoading ? '加载中…' : '该区间暂无数据' }}
/>
</Card>
</div>
);
}
+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>
);
}
+2
View File
@@ -10,6 +10,7 @@ import {
FlagOutlined,
GiftOutlined,
HeartOutlined,
LineChartOutlined,
LogoutOutlined,
MessageOutlined,
MoneyCollectOutlined,
@@ -57,6 +58,7 @@ const NAV_GROUPS: NavGroup[] = [
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
],
},
{
+21 -6
View File
@@ -624,12 +624,27 @@ export default function WithdrawsPage() {
const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0);
const ledgerIssues = ledger
? [
ledger.balance_diff_cents ? `余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
ledger.missing_withdraw_txn_count ? `缺扣款流水 ${ledger.missing_withdraw_txn_count}` : '',
ledger.missing_refund_txn_count ? `缺退款流水 ${ledger.missing_refund_txn_count}` : '',
ledger.duplicate_refund_txn_count ? `重复退款流水 ${ledger.duplicate_refund_txn_count} ` : '',
// 普通现金账(coin_cash)
ledger.balance_diff_cents ? `现金余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
ledger.missing_withdraw_txn_count ? `现金缺扣款流水 ${ledger.missing_withdraw_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}`
: '',
// 邀请奖励金账(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)
: [];
@@ -669,7 +684,7 @@ export default function WithdrawsPage() {
message={ledger.ok ? '现金账本校验正常' : '现金账本存在异常'}
description={
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('')
}
action={
+36 -2
View File
@@ -3,11 +3,12 @@
export interface AdminInfo {
id: number;
username: string;
role: string; // super_admin / finance / operator / 自定义角色名
role: string; // super_admin / finance / operator / custom(自定义) / 自定义角色名
status: string;
created_at: string;
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 → 不显示
}
@@ -233,6 +234,7 @@ export interface WithdrawBulkResult {
export interface WithdrawLedgerCheck {
ok: boolean;
// 普通现金账(coin_cash)
cash_balance_total_cents: number;
cash_transaction_total_cents: number;
balance_diff_cents: number;
@@ -240,6 +242,14 @@ export interface WithdrawLedgerCheck {
missing_refund_txn_count: number;
duplicate_refund_txn_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 {
@@ -717,3 +727,27 @@ export interface CpsStats {
total_est_commission_cents: number;
total_settled_commission_cents: number;
}
// ===== 埋点 / 上报成功率大盘(analytics_selfstat 快照差分聚合)=====
// 后端 feat/analytics-success-rate 分支的三个只读端点(/admin/api/analytics-health/*)。
// rate 分母为 0 时后端给 null(前端显示 "-",不是 0%)。
/** 一组区间聚合指标:两大率 + 四原子量。 */
export interface HealthMetrics {
attempted: number; // track 被调用次数(埋点段分母)
drop_capture: number; // 采集时异常丢弃(队列/序列化失败)
delivered: number; // 成功送达后端
drop_undelivered: number; // 已落盘但久未送达被队列淘汰
track_success_rate: number | null; // 埋点成功率 ∈ [0,1]:(attempted drop_capture) / attempted
report_success_rate: number | null; // 上报成功率 ∈ [0,1]:delivered / (delivered + drop_undelivered)
}
/** 趋势点:某北京天的指标(trend 端点,按北京天升序)。 */
export interface HealthTrendPoint extends HealthMetrics {
day: string; // 北京日期 "YYYY-MM-DD"
}
/** 下钻行:某维度值的指标(breakdown 端点,已按上报率升序,最差在前)。 */
export interface HealthBreakdownRow extends HealthMetrics {
key: string; // 维度值(event 名 / app_ver / oem);缺失为 "(unknown)"
}