Compare commits

..

4 Commits

Author SHA1 Message Date
linkeyu b8363b31ab fix: 保留跳过单券明细入口 2026-08-01 14:12:30 +08:00
linkeyu 7d4de22e6c fix: 展示中途退出无结果状态 2026-08-01 14:06:46 +08:00
linkeyu 101102e60c fix: 对齐领券成功率分母口径 2026-08-01 13:52:22 +08:00
linkeyu ca7c3f27f0 功能:新增统一限制白名单管理页面 (#99)
## 需求背景
新增统一白名单管理页面,让运营人员能够查看和调整全局限制,并按手机号或设备批量配置临时不限或风险免告警。

## 主要改动
- 新增白名单导航与完整管理页面
- 全局限制按五类分页展示并支持修改
- 白名单按主体聚合,展示各分类数量及悬浮明细
- 支持手机号与设备搜索、批量多选限制项和统一有效期
- 支持手动筛选、十条分页、主体点击编辑及启用开关
- 风控监控增加快捷加入白名单入口
- 旧引导视频配置页与统一限制入口保持一致

## 验证
- Next.js 生产构建通过
- TypeScript 类型检查通过
- 共 26 个页面完成静态/动态构建
- 已与后端 PR #207 配套验证

---------

Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #99
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-31 17:08:13 +08:00
3 changed files with 29 additions and 8 deletions
+21 -5
View File
@@ -76,6 +76,7 @@ interface CouponDataRow {
claimed_count: number | null;
point_success_count: number | null;
point_total_count: number | null;
point_event_count?: number;
// 兼容旧后端的内嵌明细;新后端不再返回,改为点击后按 trace 加载。
point_details?: CouponPointDetail[];
trace_url: string | null;
@@ -145,15 +146,22 @@ function PointScorePopover({ row }: { row: CouponDataRow }) {
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
if (row.point_success_count == null || row.point_total_count == null || row.point_total_count <= 0) {
const abandonedWithoutScoredResult =
row.status === 'abandoned' && row.point_success_count === 0 && row.point_total_count === 0;
if (
row.point_success_count == null ||
row.point_total_count == null ||
(row.point_total_count <= 0 && !abandonedWithoutScoredResult)
) {
return <Typography.Text type="secondary">-</Typography.Text>;
}
const score = `${row.point_success_count}/${row.point_total_count}`;
const scoreWithRate = `${score}${(
row.point_success_count / row.point_total_count * 100
).toFixed(1)}%`;
const hasPointDetails = (row.point_event_count ?? 0) > 0;
const scoreWithRate = abandonedWithoutScoredResult
? `0.0%${hasPointDetails ? '无有效结果' : '退出前无结果'}`
: `${score}${(row.point_success_count / row.point_total_count * 100).toFixed(1)}%`;
const scoreColor =
row.point_success_count === row.point_total_count
row.point_total_count > 0 && row.point_success_count === row.point_total_count
? STATUS_TAG.completed.color
: STATUS_TAG.abandoned.color;
const loadDetails = async () => {
@@ -174,6 +182,14 @@ function PointScorePopover({ row }: { row: CouponDataRow }) {
}
};
if (abandonedWithoutScoredResult && !hasPointDetails) {
return (
<Tooltip title="本次在第一张券产生终态前中途退出,没有可计入成功/尝试的单券结果;按运营展示口径记为 0.0%,不虚构失败券数量。">
<Typography.Text type="warning">{scoreWithRate}</Typography.Text>
</Tooltip>
);
}
return (
<Popover
trigger="click"
+6 -3
View File
@@ -550,6 +550,7 @@ export default function DashboardPage() {
const couponPeriod = periodData?.coupon ?? null;
const previousCouponPeriod = previousPeriodData?.coupon ?? null;
const couponStartedDelta = percentDelta(couponPeriod?.started, previousCouponPeriod?.started);
const couponSuccessDenominator = couponPeriod?.success_denominator ?? null;
const couponSuccessRateValue =
couponPeriod?.success_rate == null ? '--' : (couponPeriod.success_rate * 100).toFixed(1);
const couponSuccessRateDelta = pointDelta(couponPeriod?.success_rate, previousCouponPeriod?.success_rate);
@@ -875,9 +876,11 @@ export default function DashboardPage() {
unit="%"
delta={couponSuccessRateDelta.value}
deltaTone={couponSuccessRateDelta.tone}
hint={`全部点位领成功的次数 / 领券发起数;本期 ${fmtInt(couponPeriod?.all_success)} / ${fmtInt(
couponPeriod?.started,
)} 次。点位成功口径含「今日已领过」。`}
hint={`全部点位领成功的次数 ÷(领券发起数-中途退出数);本期 ${fmtInt(
couponPeriod?.all_success,
)} ÷(${fmtInt(couponPeriod?.started)}${fmtInt(couponPeriod?.abandoned)}),分母为 ${fmtInt(
couponSuccessDenominator,
)}。中途退出不计入分母,点位成功口径含「今日已领过」。`}
/>
<StatCard
title="点位成功率"
+2
View File
@@ -706,6 +706,8 @@ export interface DashboardOverview {
// 点位=一张券;成功口径 success+already_claimed;点位成功率分母=发起数×应领点位数(未跑到视为失败)。
coupon?: {
started: number;
abandoned: number;
success_denominator: number;
all_success: number; // 全部点位领成功的完成场次数
success_rate: number | null;
point_success: number;