Compare commits

...

3 Commits

Author SHA1 Message Date
linkeyu 374ac1fd90 feat(admin): 审核菜单增加未处理数量徽标 2026-07-21 16:04:29 +08:00
linkeyu 3f5356ad87 feat(admin): 领券完成率和点位成功率展示计算明细 (#53)
## 变更内容
- 将汇总卡“领券成功率”明确为“领券完成率”
- 提示中动态展示完成数、发起数、中途退出数及本期代入计算结果
- 点位成功率提示逐券展示成功数、尝试数、单券成功率和最终等权平均结果
- 展示累计成功/尝试数作为辅助核对,并明确不作为当前加权公式

## 验证
- npx tsc --noEmit
- npm run build

---------

Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #53
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 11:59:50 +08:00
linkeyu c90775d09c fix(admin): 领券广告收益未填充时显示明确文案 (#54)
原 MR #52 已合并到功能分支,无法修改目标分支。本 MR 将同一项“领券广告收益未填充时显示明确文案”修复独立提交到 main,不包含后台看板功能分支的其他提交。

验证:npx tsc --noEmit;npm run build。

Reviewed-on: #54
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 11:59:35 +08:00
2 changed files with 144 additions and 16 deletions
+44 -13
View File
@@ -73,7 +73,7 @@ interface CouponDataRow {
started_at: string;
claimed_count: number | null;
trace_url: string | null;
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
ad_revenue_yuan: number | null; // 本次领券看的信息流广告预估收益(元);未填充为 null
}
interface CouponDataReport {
date_from: string;
@@ -124,9 +124,9 @@ const fmtSec = (ms: number | null | undefined): string =>
const fmtPct = (v: number | null | undefined): string =>
v == null ? '-' : `${(v * 100).toFixed(1)}%`;
// 元(小数)→ "¥0.0050";空值-,真实 0 显示 ¥0.0000,以区分“零收益”和“无数据”
// 元(小数)→ "¥0.0050";空值eCPM 根本未填充,真实 0 显示 ¥0.0000。
const fmtYuan = (v: number | null | undefined): string =>
v == null ? '-' : `¥${v.toFixed(4)}`;
v == null ? '未填充' : `¥${v.toFixed(4)}`;
// 一组按券行的 success_rate 算术平均(每张券等权;空值券跳过);无有效券 → null(显示 -)。
// 汇总卡「分平台/合计点位成功率」据此由按券明细上卷,口径与下方按券表一致。
@@ -137,11 +137,6 @@ const slotRateMean = (rows: CouponSlotRow[]): number | null => {
return rates.length ? rates.reduce((a, b) => a + b, 0) / rates.length : null;
};
// 汇总卡成功率口径 tooltip 文案
const POINT_RATE_HINT =
'点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' +
'单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。';
// 按券表:coupon_id 平台 → 中文
const SLOT_PLATFORM: Record<string, string> = {
'meituan-waimai': '美团',
@@ -517,7 +512,7 @@ export default function CouponDataPage() {
dataIndex: 'ad_revenue_yuan',
width: 100,
align: 'right',
render: (v: number) => fmtYuan(v),
render: (v: number | null) => fmtYuan(v),
},
{
title: '美团耗时',
@@ -597,6 +592,10 @@ export default function CouponDataPage() {
const successDenominator = summary ? Math.max(0, summary.started_count - abandonedCount) : 0;
const couponSuccessRate =
summary && successDenominator > 0 ? summary.completed_count / successDenominator : null;
const validCouponSlots = couponSlots.filter((r) => r.success_rate != null);
const pointSuccessRate = slotRateMean(validCouponSlots);
const pointSucceededTotal = validCouponSlots.reduce((total, r) => total + r.succeeded, 0);
const pointTriedTotal = validCouponSlots.reduce((total, r) => total + r.tried, 0);
const items = data?.items ?? [];
return (
@@ -714,8 +713,19 @@ export default function CouponDataPage() {
<Statistic
title={
<span>
{' '}
<Tooltip title="领券完成数 ÷(领券发起数-中途退出数)">
{' '}
<Tooltip
overlayStyle={{ maxWidth: 520 }}
title={
<div>
<div> ÷退</div>
<div style={{ marginTop: 6 }}>
{summary.completed_count} ÷{summary.started_count}{abandonedCount}
{fmtPct(couponSuccessRate)}
</div>
</div>
}
>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
@@ -728,12 +738,33 @@ export default function CouponDataPage() {
title={
<span>
{' '}
<Tooltip title={POINT_RATE_HINT}>
<Tooltip
overlayStyle={{ maxWidth: 560 }}
title={
<div>
<div></div>
<div>÷ </div>
<div style={{ marginTop: 6 }}>
{validCouponSlots.length}
</div>
{validCouponSlots.map((slot) => (
<div key={slot.coupon_id}>
{slot.coupon_name || slot.coupon_id}{slot.succeeded} ÷ {slot.tried}
{fmtPct(slot.success_rate)}
</div>
))}
<div style={{ marginTop: 6 }}>
{fmtPct(pointSuccessRate)}/
{pointSucceededTotal}/{pointTriedTotal}
</div>
</div>
}
>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(slotRateMean(couponSlots))}
value={fmtPct(pointSuccessRate)}
/>
</Col>
<Col flex="1 1 0">
+100 -3
View File
@@ -25,7 +25,12 @@ import {
import { Avatar, Dropdown, Layout, Tooltip } from 'antd';
import { api } from '@/lib/api';
import { clearAuth, getAdmin, getToken, setAuth } from '@/lib/auth';
import type { AdminInfo } from '@/lib/types';
import type {
AdminInfo,
FeedbackSummary,
PriceReportSummary,
WithdrawSummary,
} from '@/lib/types';
const { Sider, Header, Content } = Layout;
@@ -35,6 +40,8 @@ type NavItem = {
label: string;
};
type ReviewNavKey = '/withdraws' | '/price-reports' | '/feedbacks';
type NavGroup =
| (NavItem & { children?: never })
| {
@@ -96,6 +103,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
const pathname = usePathname();
const [admin, setAdmin] = useState<AdminInfo | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [pendingReviewCounts, setPendingReviewCounts] = useState<Partial<Record<ReviewNavKey, number>>>({});
useEffect(() => {
const token = getToken();
@@ -114,6 +122,49 @@ export default function MainLayout({ children }: { children: React.ReactNode })
.catch(() => {});
}, [router]);
useEffect(() => {
if (!admin) return;
let cancelled = false;
const canAccess = (page: string) =>
admin.role === 'super_admin' || !admin.pages || admin.pages.includes(page);
const refresh = async () => {
const [withdraws, priceReports, feedbacks] = await Promise.all([
canAccess('withdraws')
? api.get<WithdrawSummary>('/admin/api/withdraws/summary')
.then(({ data }) => data.reviewing_count)
.catch(() => null)
: Promise.resolve(null),
canAccess('price-reports')
? api.get<PriceReportSummary>('/admin/api/price-reports/summary')
.then(({ data }) => data.pending)
.catch(() => null)
: Promise.resolve(null),
canAccess('feedbacks')
? api.get<FeedbackSummary>('/admin/api/feedbacks/summary')
.then(({ data }) => data.pending)
.catch(() => null)
: Promise.resolve(null),
]);
if (cancelled) return;
setPendingReviewCounts((current) => ({
...current,
...(withdraws == null ? {} : { '/withdraws': withdraws }),
...(priceReports == null ? {} : { '/price-reports': priceReports }),
...(feedbacks == null ? {} : { '/feedbacks': feedbacks }),
}));
};
void refresh();
const timer = window.setInterval(refresh, 30_000);
window.addEventListener('focus', refresh);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener('focus', refresh);
};
}, [admin, pathname]);
if (!admin) return null; // 守卫期间不闪烁内容
// 选中态:取路径一级(/users/123 -> /users)
@@ -132,6 +183,20 @@ export default function MainLayout({ children }: { children: React.ReactNode })
hasChildren(group) ? group.children : [group],
);
const reviewBadge = (key: string) => {
const count = pendingReviewCounts[key as ReviewNavKey] ?? 0;
if (count <= 0) return null;
return (
<span
className="nav-review-badge"
aria-label={`${count} 条未审核`}
title={`${count} 条未审核`}
>
{count}
</span>
);
};
const logout = () => {
clearAuth();
router.replace('/login');
@@ -166,6 +231,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
aria-label={item.label}
>
{item.icon}
{reviewBadge(item.key)}
</button>
</Tooltip>
))
@@ -183,7 +249,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
onClick={() => router.push(group.key)}
>
<span className="nav-primary-icon">{group.icon}</span>
<span>{group.label}</span>
<span className="nav-item-label">{group.label}</span>
{reviewBadge(group.key)}
</button>
);
}
@@ -202,7 +269,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
onClick={() => router.push(child.key)}
>
<span className="nav-child-icon">{child.icon}</span>
<span>{child.label}</span>
<span className="nav-item-label">{child.label}</span>
{reviewBadge(child.key)}
</button>
))}
</div>
@@ -269,6 +337,29 @@ export default function MainLayout({ children }: { children: React.ReactNode })
color: rgba(255, 255, 255, 0.72);
font-size: 16px;
}
.nav-item-label {
min-width: 0;
flex: 1;
}
.nav-review-badge {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: 0 0 auto;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ff4d4f;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.nav-direct,
.nav-child,
.nav-icon-button {
@@ -332,6 +423,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
padding: 0 8px 14px;
}
.nav-icon-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -342,6 +434,11 @@ export default function MainLayout({ children }: { children: React.ReactNode })
color: rgba(255, 255, 255, 0.72);
font-size: 17px;
}
.nav-icon-button .nav-review-badge {
position: absolute;
top: 1px;
right: -6px;
}
.nav-icon-button:hover,
.nav-icon-button.is-selected {
background: #1677ff;