feat(coupon-data): 点位成功率指标卡改版(品牌色圆点+环比+查看明细下钻)

- 三平台点位成功率卡:平台名前品牌色小圆点 + 大数字 + 环比涨跌徽标 + 「查看明细」按钮
- 仅「查看明细」按钮触发下钻(原整卡整块可点),下钻表改为卡内浅灰分层区域
- 新增环比:并行拉上一等长周期汇总,按百分点(pt)展示涨跌;无上期/失败显示「暂无上期」(对齐数据大盘 pointDelta 口径)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
guke
2026-07-10 11:10:32 +08:00
parent 26e468766b
commit 723d88c02c
+118 -33
View File
@@ -19,7 +19,12 @@ import {
Tooltip,
Typography,
} from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
import {
CaretDownOutlined,
CaretUpOutlined,
DownOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
@@ -137,6 +142,31 @@ const SLOT_PLATFORM: Record<string, string> = {
};
const slotPlatformLabel = (p: string | null): string => (p ? SLOT_PLATFORM[p] ?? p : '其他');
// 三平台品牌色(指标卡左侧色块用):美团黄 / 淘宝闪购橙 / 京东红。
const SLOT_PLATFORM_COLOR: Record<string, string> = {
'meituan-waimai': '#FFB300',
'taobao-shanguang': '#FF6A00',
'jd-waimai': '#E1251B',
};
// 点位成功率「环比上一等长周期」徽标:绿↑/红↓/灰持平/灰暂无上期。
// 口径对齐数据大盘 pointDelta:成功率为 0..1,差值换算成百分点(pt)。
function SlotRateDelta({ rate, prev }: { rate: number | null | undefined; prev: number | null | undefined }) {
if (rate == null || prev == null)
return <span style={{ fontSize: 12, color: '#bfbfbf' }}></span>;
const diffPt = (rate - prev) * 100;
if (Math.abs(diffPt) < 0.05) return <span style={{ fontSize: 12, color: '#8c8c8c' }}></span>;
const up = diffPt > 0;
return (
<span
style={{ fontSize: 12, color: up ? '#3f8600' : '#cf1322', display: 'inline-flex', alignItems: 'center', gap: 2 }}
>
{up ? <CaretUpOutlined /> : <CaretDownOutlined />}
{Math.abs(diffPt).toFixed(1)}pt
</span>
);
}
// 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。
const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
@@ -340,6 +370,8 @@ export default function CouponDataPage() {
const [couponSlots, setCouponSlots] = useState<CouponSlotRow[]>([]);
// 按券表:默认隐藏,点某平台点位成功率卡 → 展开该平台的券表(null=隐藏)。
const [selectedSlotPlatform, setSelectedSlotPlatform] = useState<string | null>(null);
// 上一等长周期各平台点位成功率(算环比涨跌;拉取失败为 null → 显示「暂无上期」)。
const [prevPerPlatform, setPrevPerPlatform] = useState<Record<string, number | null> | null>(null);
const [loading, setLoading] = useState(false);
// 点手机号:抽屉看该用户全部领券(总次数 + 记录)
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
@@ -356,6 +388,10 @@ export default function CouponDataPage() {
const to = range[1].format('YYYY-MM-DD');
const multiDay = from !== to;
const gran = multiDay ? 'day' : granularity;
// 环比:上一个等长周期(对齐数据大盘「环比上周期」口径)。
const spanDays = range[1].diff(range[0], 'day') + 1;
const prevTo = range[0].subtract(1, 'day');
const prevFrom = prevTo.subtract(spanDays - 1, 'day');
setLoading(true);
try {
const res = await api.get<CouponDataReport>('/admin/api/coupon-data', {
@@ -381,6 +417,23 @@ export default function CouponDataPage() {
})
.then((r) => setCouponSlots(r.data.items))
.catch(() => setCouponSlots([]));
// 并行拉「上一周期」汇总算环比(同筛选条件、上一等长周期);只用 summary,limit=1 省流量,失败按「暂无上期」。
api
.get<CouponDataReport>('/admin/api/coupon-data', {
params: {
date_from: prevFrom.format('YYYY-MM-DD'),
date_to: prevTo.format('YYYY-MM-DD'),
user: user.trim() || undefined,
app_env: appEnv,
status: statuses,
granularity: 'day',
limit: 1,
offset: 0,
},
paramsSerializer: { indexes: null },
})
.then((r) => setPrevPerPlatform(r.data.summary?.per_platform ?? null))
.catch(() => setPrevPerPlatform(null));
setPage(targetPage);
setQueriedLimit(targetLimit);
setQueriedGranularity(gran);
@@ -704,51 +757,83 @@ export default function CouponDataPage() {
</Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
<Row gutter={[16, 12]}>
{(
[
['meituan-waimai', '美团点位成功率'],
['taobao-shanguang', '淘宝点位成功率'],
['jd-waimai', '京东点位成功率'],
] as const
).map(([pid, title]) => {
{(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => {
const active = selectedSlotPlatform === pid;
const rate = summary.per_platform?.[pid];
// 左侧品牌色块 | 中间大数字+环比 | 底部「查看明细」按钮;仅按钮可展开表格。
return (
<Col flex="1 1 0" key={pid}>
<div
onClick={() => setSelectedSlotPlatform(active ? null : pid)}
title="点击查看该平台各券成功率"
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 6,
border: active ? '1px solid #1677ff' : '1px solid transparent',
background: active ? '#e6f4ff' : undefined,
border: `1px solid ${active ? '#91caff' : '#f0f0f0'}`,
borderRadius: 8,
background: active ? '#f0f7ff' : '#fff',
transition: 'all .2s',
padding: '10px 14px',
}}
>
<Statistic title={title} value={fmtPct(summary.per_platform?.[pid])} />
{/* 平台名前放小品牌色圆点(取代原左侧竖条),克制地标记平台身份,不打破页面灰度。 */}
<div style={{ fontSize: 13, color: '#666', display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
flex: '0 0 auto',
background: SLOT_PLATFORM_COLOR[pid],
}}
/>
{`${SLOT_PLATFORM[pid]}点位成功率`}
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, margin: '4px 0 10px' }}>
<span style={{ fontSize: 26, fontWeight: 600, lineHeight: 1.1 }}>{fmtPct(rate)}</span>
<SlotRateDelta rate={rate} prev={prevPerPlatform?.[pid]} />
</div>
<Button
size="small"
type={active ? 'primary' : 'default'}
onClick={() => setSelectedSlotPlatform(active ? null : pid)}
>
{' '}
<DownOutlined
style={{
fontSize: 10,
transform: active ? 'rotate(180deg)' : 'none',
transition: 'transform .2s',
}}
/>
</Button>
</div>
</Col>
);
})}
</Row>
</Card>
)}
{selectedSlotPlatform && (
<Card
size="small"
title={`${slotPlatformLabel(selectedSlotPlatform)} · 按券成功率`}
extra={<a onClick={() => setSelectedSlotPlatform(null)}></a>}
style={{ marginBottom: 16 }}
>
<Table<CouponSlotRow>
rowKey="coupon_id"
size="small"
pagination={false}
dataSource={couponSlots.filter((r) => r.platform === selectedSlotPlatform)}
columns={slotColumns}
/>
{selectedSlotPlatform && (
// 表格区域:浅灰底 + 内边距,和上方指标卡主体分层区分。
<div
style={{
marginTop: 12,
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 12,
}}
>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}
>
<span style={{ fontWeight: 500 }}>{`${slotPlatformLabel(selectedSlotPlatform)} · 按券成功率`}</span>
<a onClick={() => setSelectedSlotPlatform(null)}></a>
</div>
<Table<CouponSlotRow>
rowKey="coupon_id"
size="small"
pagination={false}
dataSource={couponSlots.filter((r) => r.platform === selectedSlotPlatform)}
columns={slotColumns}
/>
</div>
)}
</Card>
)}