diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 5288f92..1ed0091 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import { App, @@ -31,7 +31,7 @@ import { } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; -import { formatUtcTime } from '@/lib/format'; +import { formatUtcTime, percentile } from '@/lib/format'; import type { AdRevenueDaily, AdRevenueHourly, @@ -46,11 +46,11 @@ const { RangePicker } = DatePicker; // 广告类型标签 const TYPE_TAG: Record = { - reward_video: { color: 'blue', label: '激励视频' }, + reward_video: { color: 'blue', label: '看视频' }, // 历史误标 feed(领券/比价广告修 adType 之前上报)一律按 Draw 信息流显示——业务已全切 Draw feed: { color: 'geekblue', label: 'Draw 信息流' }, draw: { color: 'geekblue', label: 'Draw 信息流' }, - withdrawal_video: { color: 'gold', label: '提现激励视频' }, + withdrawal_video: { color: 'gold', label: '提现看视频' }, }; // Draw 信息流投放场景标签(后端 AdRevenueRow.feed_scene:comparison/coupon/welfare) @@ -66,15 +66,90 @@ const APP_TAG: Record = { test: { color: 'default', label: '测试应用' }, }; -// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限) -const STATUS_TAG: Record = { - granted: { color: 'green', label: '已发' }, - capped: { color: 'orange', label: '次数超限' }, - ecpm_missing: { color: 'red', label: '缺 eCPM' }, +const REWARD_STATUS_HINT: Record = { + granted: '已完成金币发放', + capped: '次数超限,未发金币', + ecpm_missing: '缺少有效 eCPM,未发金币', + too_short: '播放时长未达到发奖条件,未发金币', + closed_early: '用户提前关闭广告,未发金币', +}; + +const PLAYBACK_STATUS_TAG: Record = { + completed: { color: 'green', label: '已完成' }, too_short: { color: 'gold', label: '未满10秒' }, closed_early: { color: 'default', label: '提前关闭' }, + unknown: { color: 'default', label: '未知' }, }; +function rewardStatuses(row: AdRevenueRow): string[] { + if (row.sub_rewards?.length) return row.sub_rewards.map((reward) => reward.status); + return row.status ? [row.status] : []; +} + +function rewardStatusTag(row: AdRevenueRow) { + const statuses = rewardStatuses(row); + if (!row.has_reward || statuses.length === 0) { + return { color: 'default', label: '无记录', hint: '仅记录到广告展示,没有对应发奖记录。' }; + } + + const grantedCount = statuses.filter((status) => status === 'granted').length; + const reasonSummary = Object.entries( + statuses + .filter((status) => status !== 'granted') + .reduce>((counts, status) => { + counts[status] = (counts[status] ?? 0) + 1; + return counts; + }, {}), + ) + .map(([status, count]) => `${REWARD_STATUS_HINT[status] ?? status} ${count} 条`) + .join(';'); + + if (grantedCount === statuses.length) { + return { color: 'green', label: '已发', hint: `已完成金币发放${statuses.length > 1 ? ` ${statuses.length} 条` : ''}。` }; + } + if (grantedCount > 0) { + return { + color: 'blue', + label: '部分已发', + hint: `已发 ${grantedCount} 条,未发 ${statuses.length - grantedCount} 条${reasonSummary ? `;${reasonSummary}` : ''}。`, + }; + } + return { color: 'default', label: '未发', hint: reasonSummary ? `${reasonSummary}。` : '未发放金币。' }; +} + +function playbackStatusTag(row: AdRevenueRow) { + const statuses = rewardStatuses(row); + if (statuses.length === 0) { + return row.has_impression + ? { color: 'blue', label: '仅展示', hint: '记录到广告展示,但没有对应的播放结果。' } + : { color: 'default', label: '无记录', hint: '没有可用的广告播放记录。' }; + } + + const playbackCounts = statuses.reduce>((counts, status) => { + const playbackStatus = + status === 'too_short' || status === 'closed_early' + ? status + : status === 'granted' || status === 'capped' || status === 'ecpm_missing' + ? 'completed' + : 'unknown'; + counts[playbackStatus] = (counts[playbackStatus] ?? 0) + 1; + return counts; + }, {}); + const entries = Object.entries(playbackCounts); + if (entries.length === 1) { + const [status, count] = entries[0]; + const tag = PLAYBACK_STATUS_TAG[status]; + return { + ...tag, + hint: `${tag.label}${count > 1 ? ` ${count} 条` : ''}。`, + }; + } + const hint = entries + .map(([status, count]) => `${PLAYBACK_STATUS_TAG[status].label} ${count} 条`) + .join(';'); + return { color: 'purple', label: '混合状态', hint: `${hint}。` }; +} + const fmtFactorRange = (a: number | null, b: number | null) => { if (a == null) return '-'; return a === b || b == null ? String(a) : `${a}→${b}`; @@ -84,7 +159,6 @@ const fmtIndexRange = (a: number | null, b: number | null) => { if (a == null) return '-'; return a === b ? String(a) : `${a}–${b}`; }; - // 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。 // 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。 const COIN_PER_YUAN = 10000; @@ -104,35 +178,37 @@ const LT_FACTOR_ROWS = [ { key: '5', lt: '第 11 条及以后', factor: 1.0 }, ]; +type AdRevenueDetailRow = AdRevenueRecord & { source_adn?: string | null }; + // 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列) -const DETAIL_COLUMNS: ColumnsType = [ - { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, +const DETAIL_COLUMNS: ColumnsType = [ + { title: '来源广告网络', dataIndex: 'source_adn', width: 110, render: (value: string | null) => value || '-' }, { - title: '状态', - dataIndex: 'status', - width: 110, - render: (s: string) => { - const t = STATUS_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; - }, - }, - { title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' }, - { title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' }, - { title: '份数', dataIndex: 'units', width: 60 }, - { - title: 'LT累计条数', - key: 'lt_index', + title: 'eCPM(元)', + dataIndex: 'ecpm', width: 100, - render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end), + render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100).toFixed(2)), }, + { + title: '单次 eCPM(元)', + dataIndex: 'ecpm', + width: 120, + render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100 / 1000).toFixed(4)), + }, + { title: '实发金币数', dataIndex: 'actual_coin', width: 100 }, + { title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' }, { title: '因子2', key: 'lt_factor', width: 90, - render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), + render: (_: unknown, r: AdRevenueDetailRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), + }, + { + title: 'LT累计条数', + key: 'lt_index', + width: 100, + render: (_: unknown, r: AdRevenueDetailRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end), }, - { title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => {v} }, - { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, ]; // 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴), @@ -290,19 +366,22 @@ function TrendChart({ points }: { points: TrendPoint[] }) { // 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。 export default function AdRevenueReportPage() { const { message } = App.useApp(); - const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')]); const [userId, setUserId] = useState(null); + const [appEnv, setAppEnv] = useState<'prod' | 'test' | 'all'>('prod'); + const [revenueScope, setRevenueScope] = useState<'business' | 'all'>('business'); const [adType, setAdType] = useState(); // 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。 const [scene, setScene] = useState(); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序 - const [limit, setLimit] = useState(500); // 每页条数(分页大小) + const [limit, setLimit] = useState(1000); // 默认拉满接口单页上限,供单次流程广告数分位统计 const [page, setPage] = useState(1); // 当前页码(后端分页;1 起) const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 const [data, setData] = useState(null); - const [queriedLimit, setQueriedLimit] = useState(500); + const [allFlowItems, setAllFlowItems] = useState([]); + const [queriedLimit, setQueriedLimit] = useState(1000); const [loading, setLoading] = useState(false); const [formulaOpen, setFormulaOpen] = useState(false); // 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭) @@ -320,17 +399,22 @@ export default function AdRevenueReportPage() { const gran = multiDay ? 'day' : granularity; // 跨多天强制按天 setLoading(true); try { + const baseParams = { + date_from: from, + date_to: to, + user_id: userId ?? undefined, + app_env: appEnv === 'all' ? undefined : appEnv, + revenue_scope: revenueScope, + ad_type: adType ?? undefined, + feed_scene: scene ?? undefined, + granularity: gran, + sort: targetSort, + }; const res = await api.get('/admin/api/ad-revenue-report', { params: { - date_from: from, - date_to: to, - user_id: userId ?? undefined, - ad_type: adType ?? undefined, - feed_scene: scene ?? undefined, - granularity: gran, + ...baseParams, limit: targetLimit, offset: (targetPage - 1) * targetLimit, - sort: targetSort, }, }); setData(res.data); @@ -338,22 +422,40 @@ export default function AdRevenueReportPage() { setQueriedLimit(targetLimit); setQueriedGranularity(gran); setQueriedMultiDay(multiDay); + + // 单次流程广告数分位必须覆盖完整查询结果,不能只统计当前表格分页。 + let firstOverviewPage = res.data; + if (targetPage !== 1 || targetLimit !== 1000) { + const overviewRes = await api.get('/admin/api/ad-revenue-report', { + params: { ...baseParams, limit: 1000, offset: 0 }, + }); + firstOverviewPage = overviewRes.data; + } + const flowItems = [...firstOverviewPage.items]; + while (flowItems.length < firstOverviewPage.total) { + const nextRes = await api.get('/admin/api/ad-revenue-report', { + params: { ...baseParams, limit: 1000, offset: flowItems.length }, + }); + if (nextRes.data.items.length === 0) break; + flowItems.push(...nextRes.data.items); + } + setAllFlowItems(flowItems); } catch (e) { message.error(errMsg(e)); } finally { setLoading(false); } }, - [range, userId, adType, scene, granularity, limit, sortBy], + [range, userId, appEnv, revenueScope, adType, scene, granularity, limit, sortBy], ); useEffect(() => { load(); - // 仅首次自动拉今天;之后由「查询」按钮触发 + // 仅首次自动拉昨日;之后由「查询」按钮触发 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const columns: ColumnsType = [ + const rawColumns: ColumnsType = [ ...(queriedMultiDay ? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType[number]] : []), @@ -401,7 +503,10 @@ export default function AdRevenueReportPage() { title: '场景', dataIndex: 'feed_scene', width: 80, - render: (v: string | null | undefined) => { + render: (v: string | null | undefined, row: AdRevenueRow) => { + if (!v && (row.ad_type === 'reward_video' || row.ad_type === 'withdrawal_video')) { + return 看视频; + } if (!v) return -; const t = SCENE_TAG[v]; return t ? {t.text} : {v}; @@ -436,24 +541,32 @@ export default function AdRevenueReportPage() { }, }, { - title: '预估收益(元)', + title: '预估收益', dataIndex: 'revenue_yuan', width: 110, align: 'right', // 一次比价/领券聚合行用 row_revenue_yuan(该次发奖广告 eCPM 折算之和),其它行用展示侧 revenue_yuan render: (v: number, r: AdRevenueRow) => { const rev = r.row_revenue_yuan ?? v; - return r.has_impression || rev > 0 ? rev.toFixed(4) : '-'; + return rev.toFixed(4); }, }, { title: '发奖状态', - dataIndex: 'status', + key: 'reward_status', width: 100, - render: (s: string | null) => { - if (!s) return 仅展示; - const t = STATUS_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; + render: (_: unknown, row: AdRevenueRow) => { + const tag = rewardStatusTag(row); + return {tag.label}; + }, + }, + { + title: '广告播放状态', + key: 'playback_status', + width: 120, + render: (_: unknown, row: AdRevenueRow) => { + const tag = playbackStatusTag(row); + return {tag.label}; }, }, { @@ -473,7 +586,7 @@ export default function AdRevenueReportPage() { r.has_reward ? v : -, }, { - title: '广告位ID', + title: '广告位', dataIndex: 'our_code_id', width: 110, render: (v: string | null) => @@ -487,6 +600,27 @@ export default function AdRevenueReportPage() { v ? {v} : -, }, ]; + const columnOrder = [ + 'report_date', + 'created_at', + 'user_phone', + 'feed_scene', + 'ecpm_yuan', + 'revenue_yuan', + 'actual_coin', + 'reward_status', + 'playback_status', + 'ad_type', + 'app_env', + 'our_code_id', + ]; + const columns = columnOrder.flatMap((identity) => { + const found = rawColumns.find((column) => { + const dataIndex = 'dataIndex' in column ? column.dataIndex : undefined; + return String(column.key ?? dataIndex ?? '') === identity; + }); + return found ? [found] : []; + }) as ColumnsType; // 派生指标(全部基于全量 total_* 字段,不受分页影响,准): // 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益; @@ -525,11 +659,24 @@ export default function AdRevenueReportPage() { // 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。 const items = data?.items ?? []; + const sessionAdCounts = useMemo(() => { + const summarize = (sceneName: 'coupon' | 'comparison') => { + const counts = allFlowItems + .filter((item) => item.feed_scene === sceneName) + .map((item) => item.sub_count ?? 1); + return { + p5: percentile(counts, 0.05, false), + p50: percentile(counts, 0.5, false), + p95: percentile(counts, 0.95, false), + }; + }; + return { coupon: summarize('coupon'), comparison: summarize('comparison') }; + }, [allFlowItems]); return (
-

收益报表

+

广告收益

} > @@ -573,6 +721,7 @@ export default function AdRevenueReportPage() { onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])} allowClear={false} presets={[ + { label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] }, { label: '今天', value: [dayjs(), dayjs()] }, { label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] }, { label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] }, @@ -588,6 +737,31 @@ export default function AdRevenueReportPage() { style={{ width: 130 }} /> + + 环境 + + 类型 setPhone(e.target.value)} - onPressEnter={search} - allowClear - style={{ width: 150 }} - /> - setStore(e.target.value)} - onPressEnter={search} - allowClear - style={{ width: 150 }} - /> - setProduct(e.target.value)} - onPressEnter={search} - allowClear - style={{ width: 150 }} - /> - setPhone(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> + setStore(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> + setProduct(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> + @@ -742,37 +798,53 @@ export default function DashboardPage() {

比价核心数据

发起、成功、下单与效率 -
+
+ + + 领券核心数据 发起、整场成功、点位成功与效率
-
+
+
-

变现

+

商业化

{cpsAvailable ? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入') : (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
-
总收益
-
+
核心数据
+
- 0 && adReport?.total_revenue_yuan != null - ? `${((adReport.total_revenue_yuan / totalRevenueYuan) * 100).toFixed(1)}%` - : '--', - }, - ]} - /> - 0 && totalCpsCommissionCents != null - ? `${(((totalCpsCommissionCents / 100) / totalRevenueYuan) * 100).toFixed(1)}%` - : '--', - }, - ]} - /> + + +
@@ -934,10 +1004,6 @@ export default function DashboardPage() { 本期 4 项奖励合计发放 {fmtCoinAmount(periodData?.coins.granted_total)} 金币 - (账面负债 ≈ {fmtYuan((periodData?.coins.granted_total ?? 0) / 1000)}), + (账面负债 ≈ {fmtYuan((periodData?.coins.granted_total ?? 0) / 10000)}), 实际提现兑付 {fmtCents(periodData?.cash.withdraw_success_cents)}。 累计已发放 {fmtCoinAmount(data.coins.granted_total)} 金币但累计真实提现仅 {fmtCents(data.cash.withdraw_success_cents)},发放与现金兑付背离属正常(金币沉淀/过期); @@ -1111,6 +1178,16 @@ export default function DashboardPage() { flex-wrap: wrap; gap: 10px; } + .dashboard-date-picker { + min-width: 240px; + cursor: pointer; + } + .dashboard-date-picker:hover { + border-color: var(--brand-blue); + } + .dashboard-date-picker input { + cursor: pointer; + } .segment { display: inline-flex; gap: 2px; diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index f773e96..f94ba01 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -20,6 +20,7 @@ import type { Dayjs } from 'dayjs'; import { api } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatWallTime } from '@/lib/format'; +import { refreshReviewBadge } from '@/lib/reviewBadge'; import { usePagedList } from '@/lib/usePagedList'; import type { Feedback, FeedbackSummary } from '@/lib/types'; import FeedbackHandleDrawer from './FeedbackHandleDrawer'; @@ -431,7 +432,11 @@ export default function FeedbacksPage() { feedback={drawerFb} open={drawerOpen} onClose={() => setDrawerOpen(false)} - onDone={() => { reload(); loadSummary(); }} + onDone={() => { + reload(); + loadSummary(); + refreshReviewBadge('/feedbacks'); + }} /> diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index c1c37a0..609375c 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { BarChartOutlined, @@ -25,7 +25,16 @@ 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 { + REVIEW_BADGE_REFRESH_EVENT, + type ReviewBadgeKey, +} from '@/lib/reviewBadge'; +import type { + AdminInfo, + FeedbackSummary, + PriceReportSummary, + WithdrawSummary, +} from '@/lib/types'; const { Sider, Header, Content } = Layout; @@ -54,9 +63,9 @@ const NAV_GROUPS: NavGroup[] = [ label: '看板', children: [ { key: '/dashboard', icon: , label: '数据大盘' }, - { key: '/coupon-data', icon: , label: '领券数据' }, { key: '/ad-revenue-report', icon: , label: '广告收益' }, { key: '/comparison-records', icon: , label: '比价记录' }, + { key: '/coupon-data', icon: , label: '领券记录' }, { key: '/cps', icon: , label: 'CPS收益' }, { key: '/device-liveness', icon: , label: '设备存活' }, { key: '/analytics-health', icon: , label: '埋点成功率' }, @@ -96,6 +105,8 @@ export default function MainLayout({ children }: { children: React.ReactNode }) const pathname = usePathname(); const [admin, setAdmin] = useState(null); const [collapsed, setCollapsed] = useState(false); + const [pendingReviewCounts, setPendingReviewCounts] = useState>>({}); + const reviewRefreshVersion = useRef>>({}); useEffect(() => { const token = getToken(); @@ -114,6 +125,44 @@ export default function MainLayout({ children }: { children: React.ReactNode }) .catch(() => {}); }, [router]); + useEffect(() => { + if (!admin) return; + const canAccess = (page: string) => + admin.role === 'super_admin' || !admin.pages || admin.pages.includes(page); + + const refresh = async (key: ReviewBadgeKey) => { + if (!canAccess(key.slice(1))) return; + const version = (reviewRefreshVersion.current[key] ?? 0) + 1; + reviewRefreshVersion.current[key] = version; + let count: number; + if (key === '/withdraws') { + count = (await api.get('/admin/api/withdraws/summary')).data.reviewing_count; + } else if (key === '/price-reports') { + count = (await api.get('/admin/api/price-reports/summary')).data.pending; + } else { + count = (await api.get('/admin/api/feedbacks/summary')).data.pending; + } + if (reviewRefreshVersion.current[key] !== version) return; + setPendingReviewCounts((current) => ({ ...current, [key]: count })); + }; + + const refreshSafely = (key: ReviewBadgeKey) => refresh(key).catch(() => {}); + void Promise.all([ + refreshSafely('/withdraws'), + refreshSafely('/price-reports'), + refreshSafely('/feedbacks'), + ]); + + const onReviewCompleted = (event: Event) => { + const key = (event as CustomEvent).detail; + if (key) void refreshSafely(key); + }; + window.addEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted); + return () => { + window.removeEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted); + }; + }, [admin]); + if (!admin) return null; // 守卫期间不闪烁内容 // 选中态:取路径一级(/users/123 -> /users) @@ -132,6 +181,20 @@ export default function MainLayout({ children }: { children: React.ReactNode }) hasChildren(group) ? group.children : [group], ); + const reviewBadge = (key: string) => { + const count = pendingReviewCounts[key as ReviewBadgeKey] ?? 0; + if (count <= 0) return null; + return ( + + {count} + + ); + }; + const logout = () => { clearAuth(); router.replace('/login'); @@ -147,19 +210,14 @@ export default function MainLayout({ children }: { children: React.ReactNode }) collapsedWidth={72} width={220} onCollapse={setCollapsed} + style={{ + position: 'sticky', + top: 0, + height: '100vh', + overflowY: 'auto', + overflowX: 'hidden', + }} > -
- 运营后台 -
@@ -274,6 +335,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 { @@ -337,6 +421,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; @@ -347,6 +432,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; diff --git a/src/app/(main)/price-reports/page.tsx b/src/app/(main)/price-reports/page.tsx index 6f0e85e..d2eb62f 100644 --- a/src/app/(main)/price-reports/page.tsx +++ b/src/app/(main)/price-reports/page.tsx @@ -22,6 +22,7 @@ import dayjs from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { mediaUrl } from '@/lib/media'; +import { refreshReviewBadge } from '@/lib/reviewBadge'; import { usePagedList } from '@/lib/usePagedList'; import type { PriceReport, PriceReportSummary } from '@/lib/types'; import UserRecordsDrawer from '@/components/UserRecordsDrawer'; @@ -202,6 +203,7 @@ export default function PriceReportsPage() { onOk: async () => { try { await api.post(`/admin/api/price-reports/${r.id}/approve`); + refreshReviewBadge('/price-reports'); message.success('已通过,已发放 1000 金币'); refreshAfterChange(); } catch (e) { @@ -220,6 +222,7 @@ export default function PriceReportsPage() { } try { await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason }); + refreshReviewBadge('/price-reports'); message.success('已拒绝'); setRejecting(null); setRejectReason(''); diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index 66b46e9..666e757 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -39,6 +39,7 @@ import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime, utcDayjs, utcFromNow, yuan } from '@/lib/format'; +import { refreshReviewBadge } from '@/lib/reviewBadge'; import { usePagedList } from '@/lib/usePagedList'; import type { AuditLog, @@ -332,6 +333,7 @@ export default function WithdrawsPage() { onOk: async () => { try { await api.post(`/admin/api/withdraws/${o.out_bill_no}/approve`); + refreshReviewBadge('/withdraws'); message.success('已通过审核并发起打款'); await refreshAfterChange(o.out_bill_no); } catch (e) { @@ -362,6 +364,7 @@ export default function WithdrawsPage() { await api.post(`/admin/api/withdraws/${targets[0].out_bill_no}/reject`, { reason: trimmed }); message.success('已拒绝并退回余额'); } + refreshReviewBadge('/withdraws'); setRejecting(null); setBulkRejecting([]); setRejectReason(''); @@ -400,6 +403,7 @@ export default function WithdrawsPage() { const { data } = await api.post('/admin/api/withdraws/bulk/approve', { out_bill_nos: selectedReviewing.map((item) => item.out_bill_no), }); + refreshReviewBadge('/withdraws'); const text = bulkResultText('批量通过完成', data); if (data.failed) message.warning(text); else message.success(text); diff --git a/src/lib/format.ts b/src/lib/format.ts index 8a621e3..a1d7af3 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -23,6 +23,20 @@ const TZ = 'Asia/Shanghai'; /** 金额:分 → ¥元(两位小数)。 */ export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`; +/** + * 线性插值分位数。 + * 耗时等整数指标默认四舍五入;计数分位需要保留插值小数时传 round=false。 + */ +export function percentile(values: readonly number[], q: number, round = true): number | null { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const idx = (sorted.length - 1) * q; + const lo = Math.floor(idx); + const hi = Math.min(lo + 1, sorted.length - 1); + const result = sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo); + return round ? Math.round(result) : result; +} + const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v); /** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */ diff --git a/src/lib/reviewBadge.ts b/src/lib/reviewBadge.ts new file mode 100644 index 0000000..78e95d4 --- /dev/null +++ b/src/lib/reviewBadge.ts @@ -0,0 +1,10 @@ +export type ReviewBadgeKey = '/withdraws' | '/price-reports' | '/feedbacks'; + +export const REVIEW_BADGE_REFRESH_EVENT = 'review-badge-refresh'; + +export function refreshReviewBadge(key: ReviewBadgeKey) { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(REVIEW_BADGE_REFRESH_EVENT, { detail: key }), + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index cfd158d..caec6d6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -364,6 +364,25 @@ export interface ComparisonRecordListItem { created_at: string; } +export interface ComparisonRecordsSummary { + started: number; + completed: number; + success: number; + success_rate: number | null; + avg_token_cost: number | null; + lower_price_rate: number | null; + avg_duration_ms: number | null; + p5_duration_ms: number | null; + p50_duration_ms: number | null; + p95_duration_ms: number | null; + p99_duration_ms: number | null; + cancelled: number; + cancelled_rate: number | null; + cancelled_p5_ms: number | null; + cancelled_p50_ms: number | null; + cancelled_p95_ms: number | null; +} + // 单次 LLM 调用明细(pricebot chat() 收口落盘,server 按 trace 拉来) export interface LlmCall { ts?: number; @@ -556,11 +575,16 @@ export interface DashboardOverview { }; comparison: { total: number; + completed?: number; + cancelled?: number; success: number; - success_rate: number; + success_rate: number | null; ordered: number; average_duration_ms: number | null; + median_duration_ms?: number | null; + p95_duration_ms?: number | null; average_saved_cents: number | null; + token_cost_total_yuan?: number; }; // 领券核心数据(2026-07-05 新增;旧后端无此字段,前端 `?.` 探测)。 // 点位=一张券;成功口径 success+already_claimed;点位成功率分母=发起数×应领点位数(未跑到视为失败)。