import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend, ComposedChart, Area } from 'recharts' import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { FilterableTable } from '@/components/FilterableTable' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' import { BarChart3, TrendingDown, Users, Receipt } from 'lucide-react' import { MonthPicker } from '@/components/MonthPicker' import { useState } from 'react' const CHANNEL_COLORS: Record = { wechat: '#07c160', alipay: '#1677ff', meituan_delivery: '#ffd700', meituan: '#ff6b35', taobao_delivery: '#ff4400', douyin: '#000000', cash: '#52c41a', jd_delivery: '#e1251b', unionpay: '#f5222d', credit: '#722ed1', } export function RevenuePage() { const navigate = useNavigate() const [month, setMonth] = useState('2026-04') const { data: dailyData, isLoading: dailyLoading } = useQuery({ queryKey: ['revenue/daily-summary', month], queryFn: () => api.get('/revenue/daily-summary', { params: { month } }), }) const { data: channelData, isLoading: channelLoading } = useQuery({ queryKey: ['revenue/channel', month], queryFn: () => api.get('/revenue/channel', { params: { month } }), }) const { data: mealPeriodData, isLoading: mealLoading } = useQuery({ queryKey: ['revenue/meal-period', month], queryFn: () => api.get('/revenue/meal-period', { params: { month } }), }) const { data: storeRankingData, isLoading: rankLoading } = useQuery({ queryKey: ['revenue/store-ranking', month], queryFn: () => api.get('/revenue/store-ranking', { params: { month } }), }) const pageLoading = dailyLoading || channelLoading || mealLoading || rankLoading const dailyRows = (dailyData as any)?.data || [] const channelRows = (channelData as any)?.data || [] const mealRows = (mealPeriodData as any)?.data || [] const rankRows = (storeRankingData as any)?.data || [] // 核心指标 const totalConsumption = dailyRows.reduce((s: number, r: any) => s + Number(r.consumption || 0), 0) const totalReceived = dailyRows.reduce((s: number, r: any) => s + Number(r.received || 0), 0) const totalBills = dailyRows.reduce((s: number, r: any) => s + Number(r.bill_count || 0), 0) const totalDiscounts = dailyRows.reduce((s: number, r: any) => s + Number(r.discounts || 0), 0) const totalGuests = dailyRows.reduce((s: number, r: any) => s + Number(r.guests || 0), 0) const avgBillValue = totalBills > 0 ? totalReceived / totalBills : 0 const avgDiscountRate = totalReceived > 0 ? totalDiscounts / (totalReceived + totalDiscounts) * 100 : 0 const avgGuestValue = totalGuests > 0 ? totalReceived / totalGuests : 0 // 日度趋势数据 const dailyChart = dailyRows.map((r: any) => ({ date: r.business_date?.substring(5, 10), received: Number(r.received || 0), bills: Number(r.bill_count || 0), avg_bill: Number(r.avg_bill_value || 0), discount_rate: Number(r.discount_rate_pct || 0), })) // 环比 const last7 = dailyRows.slice(-7) const prev7 = dailyRows.slice(-14, -7) const sumKey = (arr: any[], key: string) => arr.reduce((s, r) => s + Number(r[key] || 0), 0) const trend = (curr: number, prev: number) => prev > 0 ? Math.round((curr - prev) / prev * 1000) / 10 : 0 const trendReceived = trend(sumKey(last7, 'received'), sumKey(prev7, 'received')) const trendBills = trend(sumKey(last7, 'bill_count'), sumKey(prev7, 'bill_count')) // 渠道汇总 const channelTotals: Record = {} channelRows.forEach((r: any) => { Object.keys(r).forEach((k) => { if (k !== 'business_date') { channelTotals[k] = (channelTotals[k] || 0) + Number(r[k] || 0) } }) }) const channelPie = Object.entries(channelTotals) .filter(([k, v]) => v > 0) .map(([k, v]) => ({ name: k, value: Math.round(v * 100) / 100 })) .sort((a, b) => b.value - a.value) const channelLabels: Record = { wechat: '微信', alipay: '支付宝', meituan_delivery: '美团外卖', meituan: '美团堂食', taobao_delivery: '淘宝外卖', douyin: '抖音', cash: '现金', jd_delivery: '京东外卖', unionpay: '银联', credit: '挂账', } // 渠道日度趋势(前5大渠道) const topChannels = channelPie.slice(0, 5).map((c) => c.name) const channelTrend = channelRows.map((r: any) => { const row: any = { date: r.business_date?.substring(5, 10) } topChannels.forEach((ch) => { row[ch] = Number(r[ch] || 0) }) return row }) // 时段分布 const mealColors = ['#f59e0b', '#3b82f6', '#22c55e', '#8b5cf6', '#ec4899', '#06b6d4'] const mealPie = mealRows.map((r: any, i: number) => ({ name: r.meal_period, value: Number(r.received || 0), bills: Number(r.bill_count || 0), avg_bill: Number(r.avg_bill_value || 0), fill: mealColors[i % mealColors.length], })) // 门店排名 TOP10 / BOTTOM10 const top10 = rankRows.slice(0, 10).map((r: any) => ({ name: r.store_name?.substring(0, 6) || '', value: Number(r.received || 0), code: r.store_code, })) const bottom10 = rankRows.filter((r: any) => Number(r.received) > 0).slice(-10).reverse().map((r: any) => ({ name: r.store_name?.substring(0, 6) || '', value: Number(r.received || 0), code: r.store_code, })) if (pageLoading) { return } return (
{/* 标题 */}

营收分析

收入结构 · 渠道分布 · 时段分析 · 门店排名

{/* 核心指标 */}
25 ? 'bad' : avgDiscountRate > 20 ? 'warn' : 'good'} description={`优惠率 ${formatPercent(avgDiscountRate)}`} /> 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> 0 ? '↑' : '↓'} ${Math.abs(trendBills)}%`} />
{/* 日度营收趋势 */} v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> label} formatter={(v: any, name: any) => { const labels: Record = { received: '实收', bills: '账单数', avg_bill: '客单价', discount_rate: '优惠率' } if (name === 'received') return [formatCurrency(v), '实收'] if (name === 'bills') return [formatNumber(v), '账单数'] if (name === 'avg_bill') return [formatCurrency(v), '客单价'] if (name === 'discount_rate') return [`${v}%`, '优惠率'] return [v, labels[name] || name] }} /> { const m: Record = { received: '实收', bills: '账单数', avg_bill: '客单价', discount_rate: '优惠率' }; return m[v] || v }} /> {/* 渠道收入结构 + 渠道趋势 */}
{channelPie.length > 0 && ( {channelLabels[e.name] || e.name}}> {channelPie.map((entry: any, i: number) => )} [formatCurrency(v), channelLabels[name] || name]} /> channelLabels[v] || v} /> )} v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> [formatCurrency(v), channelLabels[name] || name]} /> channelLabels[v] || v} /> {topChannels.map((ch) => ( ))}
{/* 时段收入分布 */}
{e.name}}> {mealPie.map((entry: any, i: number) => )} [formatCurrency(v), name]} />
{mealRows.map((r: any, i: number) => (

{r.meal_period}

{formatNumber(r.bill_count)} 笔 · 客单价 {formatCurrency(r.avg_bill_value)}

{formatCurrency(r.received)}

{totalReceived > 0 ? formatPercent(Number(r.received) / totalReceived * 100) : '-'}

))}
{/* 门店营收排名 */}
v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> [formatCurrency(v), '实收']} /> d.code && navigate(`/stores/${d.code}`)}> {top10.map((_: any, i: number) => )} v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> [formatCurrency(v), '实收']} /> d.code && navigate(`/stores/${d.code}`)}> {bottom10.map((_: any, i: number) => )}
{/* 门店营收明细表 */} r.store_code && navigate(`/stores/${r.store_code}`)} sortOptions={[ { key: 'received', label: '实收' }, { key: 'bill_count', label: '账单数' }, { key: 'avg_bill_value', label: '客单价' }, { key: 'avg_discount_rate_pct', label: '优惠率' }, { key: 'guests', label: '客流量' }, { key: 'avg_theoretical_margin_pct', label: '毛利率' }, ]} defaultSort="received" defaultOrder="desc" columns={[ { key: 'store_name', label: '门店' }, { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, { key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) }, { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, { key: 'avg_discount_rate_pct', label: '优惠率', align: 'right', render: (r) => 25 ? 'text-red-600 font-bold' : Number(r.avg_discount_rate_pct) > 20 ? 'text-yellow-600' : ''}>{formatPercent(r.avg_discount_rate_pct)} }, { key: 'guests', label: '客流量', align: 'right', render: (r) => formatNumber(r.guests) }, { key: 'avg_theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.avg_theoretical_margin_pct) }, ]} />
) }