From 510ce349f7593fac4d0acdd7eeb856f4f5762f35 Mon Sep 17 00:00:00 2001 From: chenshuobo Date: Sun, 28 Jun 2026 10:37:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=A4=A7=E7=9B=98=E4=B8=8E=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=88=86=E9=A1=B5=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了什么:按产品 HTML 重做数据大盘 UI,接入可确认的大盘聚合数据;将首页配置和首页轮播种子迁入系统配置页并拆分首页/福利页 Tab。 验证:npx tsc --noEmit。 --------- Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/24 Co-authored-by: chenshuobo Co-committed-by: chenshuobo --- .../HomeMarqueeSeeds.tsx | 2 +- .../{dashboard => config}/HomeStatsConfig.tsx | 2 +- src/app/(main)/config/page.tsx | 138 +- src/app/(main)/dashboard/page.tsx | 1314 ++++++++++++++++- src/lib/types.ts | 69 +- 5 files changed, 1411 insertions(+), 114 deletions(-) rename src/app/(main)/{dashboard => config}/HomeMarqueeSeeds.tsx (99%) rename src/app/(main)/{dashboard => config}/HomeStatsConfig.tsx (99%) diff --git a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx b/src/app/(main)/config/HomeMarqueeSeeds.tsx similarity index 99% rename from src/app/(main)/dashboard/HomeMarqueeSeeds.tsx rename to src/app/(main)/config/HomeMarqueeSeeds.tsx index ab915f8..05d66d3 100644 --- a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx +++ b/src/app/(main)/config/HomeMarqueeSeeds.tsx @@ -49,7 +49,7 @@ interface PreviewItem { const yuan = (cents: number) => (cents / 100).toFixed(2); -/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */ +/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「系统配置 / 首页」里的一个区块。 */ export default function HomeMarqueeSeeds() { const { message } = App.useApp(); const [seeds, setSeeds] = useState([]); diff --git a/src/app/(main)/dashboard/HomeStatsConfig.tsx b/src/app/(main)/config/HomeStatsConfig.tsx similarity index 99% rename from src/app/(main)/dashboard/HomeStatsConfig.tsx rename to src/app/(main)/config/HomeStatsConfig.tsx index 326920a..4acd667 100644 --- a/src/app/(main)/dashboard/HomeStatsConfig.tsx +++ b/src/app/(main)/config/HomeStatsConfig.tsx @@ -246,7 +246,7 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record([]); diff --git a/src/app/(main)/config/page.tsx b/src/app/(main)/config/page.tsx index 4df3570..ea90007 100644 --- a/src/app/(main)/config/page.tsx +++ b/src/app/(main)/config/page.tsx @@ -1,8 +1,10 @@ 'use client'; import { useEffect, useState } from 'react'; -import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd'; +import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tabs, Tag, Tooltip } from 'antd'; import { api, errMsg } from '@/lib/api'; +import HomeMarqueeSeeds from './HomeMarqueeSeeds'; +import HomeStatsConfig from './HomeStatsConfig'; interface ConfigItem { key: string; @@ -80,9 +82,66 @@ export default function ConfigPage() { } }; - if (loading) return ; - const groups = [...new Set(items.map((i) => i.group))]; + const welfareConfig = loading ? ( + + ) : ( + groups.map((g) => ( + + {items + .filter((i) => i.group === g) + .map((item) => ( +
+
+ {item.label} {item.overridden ? 已改 : 默认} + {item.help && ( + + + ⓘ + + + )} +
+ + {item.type === 'bool' ? ( + setEdits({ ...edits, [item.key]: v })} + /> + ) : item.type === 'int' ? ( + setEdits({ ...edits, [item.key]: v })} + style={{ width: 180 }} + /> + ) : ( + setEdits({ ...edits, [item.key]: e.target.value })} + style={{ width: 380 }} + placeholder={ + item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}' + } + /> + )} + + 默认 {JSON.stringify(item.default)} + +
+ ))} +
+ )) + ); return (
@@ -90,64 +149,21 @@ export default function ConfigPage() {

改完即生效(业务下次读取用新值)。涉及金额 / 上限,改前请确认。每次改动都进审计日志。

- {groups.map((g) => ( - - {items - .filter((i) => i.group === g) - .map((item) => ( -
-
- {item.label}{' '} - {item.overridden ? 已改 : 默认} - {item.help && ( - - - ⓘ - - - )} -
- - {item.type === 'bool' ? ( - setEdits({ ...edits, [item.key]: v })} - /> - ) : item.type === 'int' ? ( - setEdits({ ...edits, [item.key]: v })} - style={{ width: 180 }} - /> - ) : ( - setEdits({ ...edits, [item.key]: e.target.value })} - style={{ width: 380 }} - placeholder={ - item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}' - } - /> - )} - - - 默认 {JSON.stringify(item.default)} - - -
- ))} -
- ))} + + + + + ), + }, + { key: 'welfare', label: '福利页', children: welfareConfig }, + ]} + />
); } diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index 5e8e543..3fe8427 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -1,62 +1,1278 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { Alert, Card, Col, Row, Spin, Statistic } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { Alert, Select, Spin, Tag, Tooltip, message } from 'antd'; +import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; import { api } from '@/lib/api'; -import type { DashboardOverview } from '@/lib/types'; -import HomeStatsConfig from './HomeStatsConfig'; -import HomeMarqueeSeeds from './HomeMarqueeSeeds'; +import type { AdRevenueReport, DashboardOverview } from '@/lib/types'; -const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`; +type PeriodKey = 'yesterday' | '7d' | '30d'; +type TrendSeriesKey = 'dau' | 'newUsers' | 'comparisons'; +type DashboardTrendPoint = DashboardOverview['period']['trend'][number]; -export default function DashboardPage() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); +const PERIODS: { key: PeriodKey; label: string; days: number }[] = [ + { key: 'yesterday', label: '昨日', days: 1 }, + { key: '7d', label: '近 7 日', days: 7 }, + { key: '30d', label: '近 30 日', days: 30 }, +]; - useEffect(() => { - api - .get('/admin/api/stats/overview') - .then((r) => setData(r.data)) - .finally(() => setLoading(false)); - }, []); +const TREND_SERIES: { key: TrendSeriesKey; label: string; className: string }[] = [ + { key: 'dau', label: 'DAU', className: 'line-blue' }, + { key: 'newUsers', label: '新增', className: 'line-green' }, + { key: 'comparisons', label: '比价次数', className: 'line-yellow' }, +]; - if (loading || !data) return ; +const numberFmt = new Intl.NumberFormat('zh-CN'); +const moneyFmt = new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); - const cards: { title: string; value: string | number; suffix?: string; danger?: boolean }[] = [ - { title: '总用户', value: data.users.total }, - { title: '今日活跃 DAU', value: data.users.dau }, - { title: '今日新增', value: data.users.new_today }, - { title: '封禁用户', value: data.users.disabled, danger: data.users.disabled > 0 }, - { title: '累计发放金币', value: data.coins.granted_total }, - { title: '累计成功提现', value: yuan(data.cash.withdraw_success_cents) }, - { title: '待处理提现', value: data.cash.withdraw_pending_count, danger: data.cash.withdraw_pending_count > 0 }, - { title: '比价成功率', value: (data.comparison.success_rate * 100).toFixed(1), suffix: '%' }, - { title: '比价总次数', value: data.comparison.total }, - { title: '待处理反馈', value: data.feedback.new, danger: data.feedback.new > 0 }, - ]; +const fmtInt = (v: number | null | undefined) => (v == null ? '--' : numberFmt.format(v)); +const fmtCents = (cents: number | null | undefined) => (cents == null ? '--' : `¥${moneyFmt.format(cents / 100)}`); +const fmtYuan = (yuan: number | null | undefined) => (yuan == null ? '--' : `¥${moneyFmt.format(yuan)}`); +const fmtMsAsSeconds = (ms: number | null | undefined) => (ms == null ? '--' : (ms / 1000).toFixed(1)); +const pct = (v: number | null | undefined) => (v == null ? '--' : `${(v * 100).toFixed(1)}%`); +const compact = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v)); +function periodRange(period: PeriodKey) { + const p = PERIODS.find((it) => it.key === period) ?? PERIODS[0]; + const end = dayjs().subtract(1, 'day'); + const start = end.subtract(p.days - 1, 'day'); + return { start, end, label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}`, days: p.days }; +} + +function retentionTitle(period: PeriodKey) { + if (period === '7d') return '近 7 日新增留存'; + if (period === '30d') return '近 30 日新增留存'; + return '昨日新增留存'; +} + +function avgEcpm(report: AdRevenueReport | null) { + if (!report || report.total_impressions <= 0) return null; + return (report.total_revenue_yuan / report.total_impressions) * 1000; +} + +function adTypeSummary(report: AdRevenueReport | null, adType: string) { + if (!report) return null; + if (report.by_ad_type?.[adType]) return report.by_ad_type[adType]; + const matched = report.items.filter((it) => it.ad_type === adType); + if (matched.length === 0) return null; + return { + ad_type: adType, + impressions: matched.reduce((sum, it) => sum + it.impressions, 0), + revenue_yuan: matched.reduce((sum, it) => sum + it.revenue_yuan, 0), + expected_coin: matched.reduce((sum, it) => sum + it.expected_coin, 0), + actual_coin: matched.reduce((sum, it) => sum + it.actual_coin, 0), + }; +} + +function adSceneSummary(report: AdRevenueReport | null, feedScene: 'coupon' | 'comparison') { + if (!report) return null; + const matched = report.items.filter((it) => it.feed_scene === feedScene); + if (matched.length === 0) return null; + return { + impressions: matched.reduce((sum, it) => sum + it.impressions, 0), + revenue_yuan: matched.reduce((sum, it) => sum + it.revenue_yuan, 0), + }; +} + +function sceneAdText( + loading: boolean, + report: AdRevenueReport | null, + summary: ReturnType, +) { + if (loading || !report) return '--'; + return fmtYuan(summary?.revenue_yuan ?? 0); +} + +function sceneAdEcpm(summary: ReturnType) { + if (!summary || summary.impressions <= 0) return '--'; + return fmtYuan((summary.revenue_yuan / summary.impressions) * 1000); +} + +function niceMax(v: number) { + if (v <= 5) return 5; + if (v <= 10) return 10; + const base = 10 ** Math.floor(Math.log10(v)); + return Math.ceil(v / base) * base; +} + +function StatusText({ children, tone = 'muted' }: { children: React.ReactNode; tone?: 'muted' | 'ok' | 'warn' }) { + return {children}; +} + +function DeltaBadge({ + children, + tone = 'muted', +}: { + children: React.ReactNode; + tone?: 'muted' | 'up' | 'down'; +}) { + return {children}; +} + +function MiniSpark({ + color = '#2F6BFF', + down, +}: { + color?: string; + down?: boolean; +}) { + const points = down + ? '0,8 13,10 26,9 39,14 52,12 65,18 78,20' + : '0,24 13,22 26,20 39,15 52,13 65,8 78,5'; return ( -
-

数据大盘

- - {cards.map((c) => ( - - - - - - ))} - - {!data.cps.available && ( - - )} - - + + + + ); +} + +function StatCard({ + title, + value, + unit, + hint, + status, + tone, + emphasis, + spark, + delta, + deltaTone, +}: { + title: string; + value: string | number; + unit?: string; + hint?: string; + status?: string; + tone?: 'muted' | 'ok' | 'warn'; + emphasis?: boolean; + spark?: 'up' | 'down'; + delta?: string; + deltaTone?: 'muted' | 'up' | 'down'; +}) { + return ( +
+
+ {title} + {hint && ( + + + + )} +
+
+
+ {value} + {unit && value !== '--' && {unit}} +
+ {spark && } +
+ {delta ? {delta} : status && {status}} +
+ ); +} + +function RevenueCard({ + title, + value, + meta, + accent, + delta, +}: { + title: string; + value: string; + meta?: { label: string; value: string }[]; + accent?: 'blue' | 'green' | 'yellow' | 'red'; + delta?: string; +}) { + return ( +
+
+
{title}
+ {delta && {delta}} +
+
{value}
+ {meta && meta.length > 0 && ( +
+ {meta.map((it) => ( + + {it.label} {it.value} + + ))} +
+ )} +
+ ); +} + +function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: DashboardTrendPoint[] }) { + const [visibleSeries, setVisibleSeries] = useState>({ + dau: true, + newUsers: true, + comparisons: true, + }); + const seriesConfig = { + dau: { field: 'active_users', color: '#2F6BFF' }, + newUsers: { field: 'new_users', color: '#18B368' }, + comparisons: { field: 'comparisons', color: '#FFB300' }, + } satisfies Record; + const chart = { left: 44, right: 880, top: 28, bottom: 210 }; + const visibleKeys = TREND_SERIES.map((s) => s.key).filter((key) => visibleSeries[key]); + const maxValue = niceMax( + Math.max( + 0, + ...points.flatMap((p) => visibleKeys.map((key) => Number(p[seriesConfig[key].field]) || 0)), + ), + ); + const xFor = (idx: number) => + points.length <= 1 + ? (chart.left + chart.right) / 2 + : chart.left + ((chart.right - chart.left) * idx) / (points.length - 1); + const yFor = (value: number) => + chart.bottom - ((chart.bottom - chart.top) * value) / maxValue; + const seriesPoints = (key: TrendSeriesKey) => + points + .map((p, idx) => `${xFor(idx)},${yFor(Number(p[seriesConfig[key].field]) || 0)}`) + .join(' '); + const areaPath = (key: TrendSeriesKey) => { + if (points.length < 2) return ''; + const line = points + .map((p, idx) => `L${xFor(idx)},${yFor(Number(p[seriesConfig[key].field]) || 0)}`) + .join(' '); + return `M${chart.left},${chart.bottom} ${line.replace(/^L/, 'L')} L${chart.right},${chart.bottom} Z`; + }; + const labelStep = Math.max(1, Math.ceil(points.length / 6)); + + const toggleSeries = (key: TrendSeriesKey) => { + setVisibleSeries((prev) => { + const visibleCount = Object.values(prev).filter(Boolean).length; + if (prev[key] && visibleCount === 1) return prev; + return { ...prev, [key]: !prev[key] }; + }); + }; + + return ( +
+
+
+ {TREND_SERIES.map((series) => ( + + ))} +
+
+ + +
+
+
+ + + + + + + + + + {[1, 0.75, 0.5, 0.25, 0].map((ratio, idx) => ( + + {compact(Math.round(maxValue * ratio))} + + ))} + + {points.length === 0 ? ( + + 暂无趋势数据 + + ) : ( + <> + {visibleSeries.dau && points.length > 1 && ( + + )} + {TREND_SERIES.map((series) => + visibleSeries[series.key] ? ( + + {points.length > 1 && ( + + )} + {points.map((p, idx) => ( + + ))} + + ) : null, + )} + + {points.map((p, idx) => + idx === 0 || idx === points.length - 1 || idx % labelStep === 0 ? ( + + {dayjs(p.date).format('MM-DD')} + + ) : null, + )} + + + )} + +
真实趋势 · {rangeLabel} · 北京自然日
+
+
+ ); +} + +export default function DashboardPage() { + const [period, setPeriod] = useState('yesterday'); + const [data, setData] = useState(null); + const [adReport, setAdReport] = useState(null); + const [loading, setLoading] = useState(true); + const [adLoading, setAdLoading] = useState(false); + const [overviewError, setOverviewError] = useState(null); + const [adError, setAdError] = useState(null); + const [updatedAt, setUpdatedAt] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const [cpsSyncing, setCpsSyncing] = useState(false); + + const range = useMemo(() => periodRange(period), [period]); + const periodData = data?.period ?? null; + const adAvgEcpm = avgEcpm(adReport); + const rewardVideoAdSummary = adTypeSummary(adReport, 'reward_video'); + const couponAdSummary = adSceneSummary(adReport, 'coupon'); + const comparisonAdSummary = adSceneSummary(adReport, 'comparison'); + const rewardVideoAdText = adLoading || !adReport ? '--' : fmtYuan(rewardVideoAdSummary?.revenue_yuan ?? 0); + const rewardVideoEcpm = + rewardVideoAdSummary && rewardVideoAdSummary.impressions > 0 + ? fmtYuan((rewardVideoAdSummary.revenue_yuan / rewardVideoAdSummary.impressions) * 1000) + : '--'; + const compareSuccessRate = periodData?.comparison.success_rate ?? null; + const averageDurationMs = periodData?.comparison.average_duration_ms ?? null; + const couponRewardCoinTotal = periodData?.coins.coupon_reward_coin_total; + const comparisonRewardCoinTotal = periodData?.coins.comparison_reward_coin_total; + const signinCoinTotal = periodData?.coins.signin_coin_total; + const signinBoostCoinTotal = periodData?.coins.signin_boost_coin_total; + const taskCoinTotal = periodData?.coins.task_coin_total; + const regularTaskCoinTotal = + periodData?.coins.regular_task_coin_total ?? + ((signinCoinTotal ?? 0) + (signinBoostCoinTotal ?? 0) + (taskCoinTotal ?? 0)); + const cpsCommissionCents = data?.cps.meituan_commission_cents; + const cpsAvailable = data?.cps.available === true; + const totalRevenueYuan = + adReport?.total_revenue_yuan == null && (!cpsAvailable || cpsCommissionCents == null) + ? null + : (adReport?.total_revenue_yuan ?? 0) + (cpsAvailable ? (cpsCommissionCents ?? 0) / 100 : 0); + const yesterdayTrendPoint = periodData?.trend?.[periodData.trend.length - 1]; + const yesterdayActiveUsers = yesterdayTrendPoint?.active_users ?? null; + const arpuYuan = + totalRevenueYuan == null || yesterdayActiveUsers == null || yesterdayActiveUsers <= 0 + ? null + : totalRevenueYuan / yesterdayActiveUsers; + const cpsRevenueText = cpsAvailable ? fmtCents(cpsCommissionCents) : '--'; + const cpsHitRate = !cpsAvailable || data?.cps.meituan_hit_rate == null ? '--' : pct(data.cps.meituan_hit_rate); + const retentionValue = + periodData?.users.retention_rate == null + ? '--' + : (periodData.users.retention_rate * 100).toFixed(1); + + useEffect(() => { + let alive = true; + setLoading(true); + setOverviewError(null); + api + .get('/admin/api/stats/overview', { + params: { + date_from: range.start.format('YYYY-MM-DD'), + date_to: range.end.format('YYYY-MM-DD'), + }, + }) + .then((r) => { + if (!alive) return; + setData(r.data); + setUpdatedAt(dayjs().format('YYYY-MM-DD HH:mm')); + }) + .catch(() => { + if (!alive) return; + setOverviewError('总览接口加载失败'); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [range.start, range.end, refreshKey]); + + useEffect(() => { + let alive = true; + setAdLoading(true); + setAdError(null); + api + .get('/admin/api/ad-revenue-report', { + params: { + date_from: range.start.format('YYYY-MM-DD'), + date_to: range.end.format('YYYY-MM-DD'), + granularity: 'day', + limit: 1000, + }, + }) + .then((r) => { + if (!alive) return; + setAdReport(r.data); + }) + .catch(() => { + if (!alive) return; + setAdReport(null); + setAdError('广告收益接口加载失败'); + }) + .finally(() => { + if (alive) setAdLoading(false); + }); + return () => { + alive = false; + }; + }, [range.start, range.end]); + + if (loading) return ; + + if (!data) { + return ; + } + + const syncMeituanCps = async () => { + setCpsSyncing(true); + try { + const resp = await api.post<{ + fetched: number; + inserted: number; + updated: number; + pages: number; + }>('/admin/api/cps/orders/reconcile', null, { + params: { + date_from: range.start.format('YYYY-MM-DD'), + date_to: range.end.format('YYYY-MM-DD'), + }, + }); + message.success(`已同步 ${resp.data.fetched} 单`); + setRefreshKey((v) => v + 1); + } catch { + message.error('美团订单刷新失败'); + } finally { + setCpsSyncing(false); + } + }; + + return ( +
+
运营后台 / 数据大盘
+
+
+

数据大盘

+

+ 数据更新于 {updatedAt ?? '--'} · 北京时间 · 日期窗口按 00:00 自然日切分,不包含今日 +

+
+
+
+ {PERIODS.map((it) => ( + + ))} +
+
+ + {range.label} +
+