feat(coupon-data): 点位成功率指标卡改版(品牌色圆点+环比+查看明细下钻)
- 三平台点位成功率卡:平台名前品牌色小圆点 + 大数字 + 环比涨跌徽标 + 「查看明细」按钮 - 仅「查看明细」按钮触发下钻(原整卡整块可点),下钻表改为卡内浅灰分层区域 - 新增环比:并行拉上一等长周期汇总,按百分点(pt)展示涨跌;无上期/失败显示「暂无上期」(对齐数据大盘 pointDelta 口径) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,44 +757,74 @@ 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 }}
|
||||
// 表格区域:浅灰底 + 内边距,和上方指标卡主体分层区分。
|
||||
<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"
|
||||
@@ -749,6 +832,8 @@ export default function CouponDataPage() {
|
||||
dataSource={couponSlots.filter((r) => r.platform === selectedSlotPlatform)}
|
||||
columns={slotColumns}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user