From 3102a96bd34200f51d57b8abe8ab3b050f430241 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Tue, 30 Jun 2026 22:59:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=90=E8=90=A5=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E3=80=8C=E9=A2=86=E5=88=B8=E6=95=B0=E6=8D=AE=E3=80=8D=E9=A1=B5?= =?UTF-8?q?=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧边栏在「用户反馈」后、「广告配置」前加「领券数据」菜单(/coupon-data)。 - 两段式:汇总卡(领券发起数 / 完成数 / 平均耗时 + 耗时 P5/P50/P95/P99)+ 自研零依赖 SVG 趋势图 (发起 / 完成数双柱 + 平均耗时线,按天 / 按小时)+ 逐条领券明细表。 - 「发起平台」=发起来源(傻瓜比价 / 美团 / 淘宝 / 京东);trace 列有 trace_url 给可点链接; 手机号列可点 → 复用通用 UserRecordsDrawer 看该用户全部领券(为其加了可选 rewardSuffix prop)。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/31 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- src/app/(main)/coupon-data/page.tsx | 677 +++++++++++++++++++++++++++ src/app/(main)/layout.tsx | 2 + src/components/UserRecordsDrawer.tsx | 4 +- 3 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/coupon-data/page.tsx diff --git a/src/app/(main)/coupon-data/page.tsx b/src/app/(main)/coupon-data/page.tsx new file mode 100644 index 0000000..01590dc --- /dev/null +++ b/src/app/(main)/coupon-data/page.tsx @@ -0,0 +1,677 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import { + App, + Button, + Card, + Col, + DatePicker, + Divider, + Input, + Row, + Select, + Space, + Statistic, + Table, + Tag, + Typography, +} from 'antd'; +import dayjs, { type Dayjs } from 'dayjs'; +import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; +import UserRecordsDrawer from '@/components/UserRecordsDrawer'; + +const { RangePicker } = DatePicker; + +// ── 领券数据看板类型(仅本页用,内联定义)── +interface CouponDataSummary { + started_count: number; + completed_count: number; + avg_elapsed_ms: number | null; + p5_ms: number | null; + p50_ms: number | null; + p95_ms: number | null; + p99_ms: number | null; +} +interface CouponDataDaily { + date: string; + started_count: number; + completed_count: number; + avg_elapsed_ms: number | null; +} +interface CouponDataHourly { + hour: number; + started_count: number; + completed_count: number; + avg_elapsed_ms: number | null; +} +interface CouponDataRow { + id: number; + trace_id: string; + user_id: number | null; + user_phone: string | null; + user_nickname: string | null; + status: string; + platforms: string[] | null; + origin_package: string | null; + elapsed_ms: number | null; + platform_elapsed: Record | null; + device_model: string | null; + rom: string | null; + app_env: string | null; + started_at: string; + claimed_count: number | null; + trace_url: string | null; +} +interface CouponDataReport { + date_from: string; + date_to: string; + summary: CouponDataSummary; + daily: CouponDataDaily[]; + hourly: CouponDataHourly[]; + total: number; + items: CouponDataRow[]; +} + +// 发起来源 App 包名 → 中文(「发起平台」列):空=傻瓜比价首页发起,外卖 App 包名→对应平台。 +function originLabel(pkg: string | null): string { + if (!pkg) return '傻瓜比价'; + if (pkg.includes('meituan') || pkg.includes('sankuai')) return '美团'; + if (pkg.includes('taobao') || pkg.includes('ele')) return '淘宝'; + if (pkg.includes('jingdong') || pkg.includes('jd')) return '京东'; + return pkg; +} + +// 领券状态 → 颜色 + 中文(started 无终态 = 中途流失) +const STATUS_TAG: Record = { + started: { color: 'default', label: '未完成' }, + completed: { color: 'green', label: '完成' }, + failed: { color: 'red', label: '失败' }, + abandoned: { color: 'orange', label: '中途退出' }, +}; + +// ms → "1.5s"(空值显示 -) +const fmtSec = (ms: number | null | undefined): string => + ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`; + +// 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。 +const RECORD_COLUMNS: ColumnsType = [ + { title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string) => { + const t = STATUS_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { title: '发起平台', dataIndex: 'origin_package', width: 90, render: (pkg: string | null) => originLabel(pkg) }, + { title: '耗时', dataIndex: 'elapsed_ms', width: 80, align: 'right', render: (v: number | null) => fmtSec(v) }, + { title: '美团', key: 'mt', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']) }, + { title: '淘宝', key: 'tb', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']) }, + { title: '京东', key: 'jd', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']) }, + { + title: 'trace', + dataIndex: 'trace_id', + width: 90, + render: (v: string, r: CouponDataRow) => + r.trace_url ? ( + trace + ) : ( + {v.slice(0, 6)}… + ), + }, +]; + +// ── 趋势图(纯 SVG,零依赖,复用广告报表同款):浅蓝柱=发起数、深蓝柱=完成数(左轴 次数),橙线=平均耗时(右轴 秒)── +const CHART_BAR_STARTED = '#bae0ff'; +const CHART_BAR_COMPLETED = '#1677ff'; +const CHART_LINE = '#fa8c16'; + +interface TrendPoint { + label: string; // x 轴刻度(小时数 / MM-DD) + tip: string; // hover 原生 tooltip + started: number; + completed: number; + avgSec: number | null; // 平均耗时(秒);该桶无完成为 null(不画线点) +} + +// 按天聚合:用 from..to 补齐空缺日为 0,轴连续。 +function aggregateDaily(dateFrom: string, dateTo: string, daily: CouponDataDaily[]): TrendPoint[] { + const map = new Map(daily.map((d) => [d.date, d])); + const out: TrendPoint[] = []; + let cur = dayjs(dateFrom); + let guard = 0; + while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) { + const ds = cur.format('YYYY-MM-DD'); + const d = map.get(ds); + const started = d?.started_count ?? 0; + const completed = d?.completed_count ?? 0; + const avgSec = d?.avg_elapsed_ms != null ? d.avg_elapsed_ms / 1000 : null; + out.push({ + label: ds.slice(5), + tip: `${ds} 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`, + started, + completed, + avgSec, + }); + cur = cur.add(1, 'day'); + guard += 1; + } + return out; +} + +// 按小时聚合(0–23)。 +function aggregateHourly(rows: CouponDataHourly[]): TrendPoint[] { + const byHour = new Map(rows.map((r) => [r.hour, r])); + return Array.from({ length: 24 }, (_, h) => { + const r = byHour.get(h); + const started = r?.started_count ?? 0; + const completed = r?.completed_count ?? 0; + const avgSec = r?.avg_elapsed_ms != null ? r.avg_elapsed_ms / 1000 : null; + return { + label: String(h), + tip: `${String(h).padStart(2, '0')}:00 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`, + started, + completed, + avgSec, + }; + }); +} + +function TrendChart({ points }: { points: TrendPoint[] }) { + const n = points.length; + const maxBar = Math.max(1, ...points.map((p) => p.started)); + const maxSec = Math.max(1e-9, ...points.map((p) => p.avgSec ?? 0)); + + const W = 960; + const H = 280; + const padL = 48; + const padR = 56; + const padT = 16; + const padB = 32; + const plotW = W - padL - padR; + const plotH = H - padT - padB; + const step = plotW / Math.max(1, n); + const barW = Math.min(24, step * 0.5); + const yBase = padT + plotH; + + const barX = (i: number) => padL + i * step + (step - barW) / 2; + const cx = (i: number) => padL + i * step + step / 2; + const barH = (v: number) => (v / maxBar) * plotH; + const secY = (v: number) => yBase - (v / maxSec) * plotH; + // X 轴刻度按天/按小时尽量每格都标(≤31 个点每个都标,超出才抽稀),避免跨度稍大就隔天显示。 + const labelEvery = Math.max(1, Math.ceil(n / 31)); + + // 平均耗时线:仅 avgSec 非 null 的点连线(无完成的桶断开)。 + const linePts = points + .map((p, i) => (p.avgSec == null ? null : `${cx(i)},${secY(p.avgSec)}`)) + .filter((x): x is string => x !== null) + .join(' '); + + return ( + + {[0, 0.25, 0.5, 0.75, 1].map((t) => { + const y = yBase - t * plotH; + return ( + + + + {Math.round(maxBar * t)} + + + {(maxSec * t).toFixed(1)} + + + ); + })} + {/* 发起数柱(浅) */} + {points.map((p, i) => ( + + {p.tip} + + ))} + {/* 完成数柱(深,窄,叠中间) */} + {points.map((p, i) => { + const cw = barW * 0.55; + return ( + + {p.tip} + + ); + })} + {/* 平均耗时线(橙,右轴) */} + + {points.map((p, i) => + p.avgSec == null ? null : ( + + {p.tip} + + ), + )} + {points.map((p, i) => + i % labelEvery === 0 || i === n - 1 ? ( + + {p.label} + + ) : null, + )} + + ); +} + +// 领券数据看板:上半汇总卡 + 按天/小时趋势(参考广告收益大盘),下半逐条领券明细。 +// 数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报);耗时统计只算 completed。 +export default function CouponDataPage() { + const { message } = App.useApp(); + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]); + const [user, setUser] = useState(''); + const [appEnv, setAppEnv] = useState('prod'); + const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); + const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time'); + const [limit, setLimit] = useState(100); + const [page, setPage] = useState(1); + const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); + const [queriedMultiDay, setQueriedMultiDay] = useState(false); + const [queriedLimit, setQueriedLimit] = useState(100); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + // 点手机号:抽屉看该用户全部领券(总次数 + 记录) + const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null); + const openUserRecords = (r: CouponDataRow) => { + if (r.user_id != null) setRecordsUser({ userId: r.user_id, phone: r.user_phone }); + }; + + // 跨多天时「按小时」无意义,粒度强制按天(同广告报表)。 + const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD'); + + const load = useCallback( + async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => { + const from = range[0].format('YYYY-MM-DD'); + const to = range[1].format('YYYY-MM-DD'); + const multiDay = from !== to; + const gran = multiDay ? 'day' : granularity; + setLoading(true); + try { + const res = await api.get('/admin/api/coupon-data', { + params: { + date_from: from, + date_to: to, + user: user.trim() || undefined, + app_env: appEnv, + granularity: gran, + limit: targetLimit, + offset: (targetPage - 1) * targetLimit, + sort: targetSort, + }, + }); + setData(res.data); + setPage(targetPage); + setQueriedLimit(targetLimit); + setQueriedGranularity(gran); + setQueriedMultiDay(multiDay); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLoading(false); + } + }, + [range, user, appEnv, granularity, limit, sortBy, message], + ); + + useEffect(() => { + load(); + // 仅首次自动拉近 7 天;之后由「查询」按钮触发 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const columns: ColumnsType = [ + { + title: '用户', + dataIndex: 'user_nickname', + width: 140, + render: (_: unknown, r: CouponDataRow) => { + if (r.user_nickname) { + return ( + + {r.user_nickname} + {r.user_id != null && ( + + #{r.user_id} + + )} + + ); + } + if (r.user_id != null) return #{r.user_id}; + return 游客; + }, + }, + { + title: '手机号', + dataIndex: 'user_phone', + width: 130, + // 点手机号 → 抽屉看该用户全部领券(参考低价审核/反馈页)。游客(无 user_id)不可点。 + render: (phone: string | null, r: CouponDataRow) => + r.user_id != null ? ( + openUserRecords(r)}>{phone || `#${r.user_id}`} + ) : ( + 游客 + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s: string) => { + const t = STATUS_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { + title: '发起平台', + dataIndex: 'origin_package', + width: 110, + render: (pkg: string | null) => originLabel(pkg), + }, + { + title: '耗时', + dataIndex: 'elapsed_ms', + width: 90, + align: 'right', + render: (v: number | null) => fmtSec(v), + }, + { + title: '美团耗时', + key: 'mt_elapsed', + width: 90, + align: 'right', + render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']), + }, + { + title: '淘宝耗时', + key: 'tb_elapsed', + width: 90, + align: 'right', + render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']), + }, + { + title: '京东耗时', + key: 'jd_elapsed', + width: 90, + align: 'right', + render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']), + }, + { + title: '机型/ROM', + key: 'device', + width: 200, + render: (_: unknown, r: CouponDataRow) => { + const parts = [r.device_model, r.rom].filter(Boolean); + return parts.length ? parts.join(' / ') : -; + }, + }, + { + title: '时间', + dataIndex: 'started_at', + width: 165, + render: (v: string) => formatUtcTime(v), + }, + { + title: '领券 trace', + dataIndex: 'trace_id', + width: 150, + // 有 trace_url(pricebot 上云,仅 completed)→ 可点链接打开完整现场;否则降级为可复制 trace_id。 + render: (v: string, r: CouponDataRow) => + r.trace_url ? ( + + trace({v.slice(0, 8)}…) + + ) : ( + + {v.slice(0, 8)}… + + ), + }, + ]; + + const summary = data?.summary; + const items = data?.items ?? []; + + return ( +
+ +

领券数据

+ + 发起→完成耗时口径为客户端全程计时;均值/分位仅统计「完成」的领券 + +
+ + + + + 日期 + v && v[0] && v[1] && setRange([v[0], v[1]])} + allowClear={false} + presets={[ + { label: '今天', value: [dayjs(), dayjs()] }, + { label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] }, + { label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] }, + ]} + /> + + + 用户 + setUser(e.target.value)} + onPressEnter={() => load(1)} + allowClear + style={{ width: 150 }} + /> + + + 环境 + + + + 排序 + { + setLimit(v); + load(1, v); + }} + style={{ width: 120 }} + options={[20, 50, 100, 200, 500].map((nn) => ({ value: nn, label: `${nn} 条/页` }))} + /> + + + + + + {summary && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + {data && + (queriedMultiDay + ? (data.daily?.length ?? 0) > 0 + : queriedGranularity === 'hour' && (data.hourly?.length ?? 0) > 0) && ( + + + + 发起数 + + + + 完成数 + + + + 平均耗时(秒) + + + } + > + {queriedMultiDay ? ( + + ) : ( + + )} + + )} + + `共 ${t} 条`, + onChange: (p) => load(p, queriedLimit), + }} + size="small" + scroll={{ x: 1450 }} + /> + + + open={recordsUser != null} + onClose={() => setRecordsUser(null)} + userId={recordsUser?.userId ?? null} + phone={recordsUser?.phone ?? null} + endpoint="/admin/api/coupon-data/user-records" + columns={RECORD_COLUMNS} + recordLabel="领券" + countLabel="领券次数" + rewardLabel="领到券张数" + rewardSuffix="张" + rewardOf={(r) => r.claimed_count ?? 0} + /> + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 3158bb2..cccada5 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -8,6 +8,7 @@ import { DatabaseOutlined, FileSearchOutlined, FlagOutlined, + GiftOutlined, HeartOutlined, LogoutOutlined, MessageOutlined, @@ -35,6 +36,7 @@ const MENU = [ { key: '/price-reports', icon: , label: '低价审核' }, { key: '/comparison-records', icon: , label: '比价记录' }, { key: '/feedbacks', icon: , label: '用户反馈' }, + { key: '/coupon-data', icon: , label: '领券数据' }, { key: '/ad-revenue', icon: , label: '广告配置' }, { key: '/ad-revenue-report', icon: , label: '广告收益' }, { key: '/cps', icon: , label: 'CPS 分发' }, diff --git a/src/components/UserRecordsDrawer.tsx b/src/components/UserRecordsDrawer.tsx index 4979588..504fc31 100644 --- a/src/components/UserRecordsDrawer.tsx +++ b/src/components/UserRecordsDrawer.tsx @@ -21,6 +21,7 @@ interface Props { countLabel: string; // 顶部计数卡标题,如「提交总计」/「上报总计」 rewardLabel: string; // 顶部奖励卡标题,如「提交奖励总计」/「上报奖励总计」 rewardOf: (item: T) => number; // 单条已发奖励金币(未采纳/未通过返回 0) + rewardSuffix?: string; // 第二个汇总卡单位,默认「金币」;领券场景可传「张」(领到券张数)等 } export default function UserRecordsDrawer({ @@ -34,6 +35,7 @@ export default function UserRecordsDrawer({ countLabel, rewardLabel, rewardOf, + rewardSuffix = '金币', }: Props) { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); @@ -91,7 +93,7 @@ export default function UserRecordsDrawer({ <> - + {items.length === 0 ? (