173 lines
8.4 KiB
TypeScript
173 lines
8.4 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import api from '@/lib/api'
|
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
|
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
|
import { MetricCard } from '@/components/MetricCard'
|
|
import { formatCurrency, formatNumber } from '@/lib/utils'
|
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts'
|
|
|
|
const CHANNEL_COLORS: Record<string, string> = {
|
|
cash: '#22c55e',
|
|
alipay: '#3b82f6',
|
|
wechat: '#06b6d4',
|
|
meituan: '#f59e0b',
|
|
unionpay: '#8b5cf6',
|
|
douyin: '#ec4899',
|
|
credit: '#ef4444',
|
|
jd_delivery: '#f97316',
|
|
meituan_delivery: '#f59e0b',
|
|
taobao_delivery: '#eab308',
|
|
}
|
|
|
|
const CHANNEL_LABELS: Record<string, string> = {
|
|
cash: '现金',
|
|
alipay: '支付宝',
|
|
wechat: '微信',
|
|
meituan: '美团到店',
|
|
unionpay: '银联',
|
|
douyin: '抖音',
|
|
credit: '挂账',
|
|
jd_delivery: '京东到家',
|
|
meituan_delivery: '美团外卖',
|
|
taobao_delivery: '淘宝外卖',
|
|
meituan_commission: '美团佣金',
|
|
taobao_commission: '淘宝佣金',
|
|
jd_commission: '京东佣金',
|
|
}
|
|
|
|
const channelLabel = (k: string) => CHANNEL_LABELS[k] || k
|
|
|
|
export function TimePage() {
|
|
const { data: weekdayData, isLoading: weekdayLoading } = useQuery({
|
|
queryKey: ['time/weekday'],
|
|
queryFn: () => api.get('/time/weekday'),
|
|
})
|
|
|
|
const { data: hourlyData, isLoading: hourlyLoading } = useQuery({
|
|
queryKey: ['time/hourly'],
|
|
queryFn: () => api.get('/time/hourly'),
|
|
})
|
|
|
|
const { data: channelData, isLoading: channelLoading } = useQuery({
|
|
queryKey: ['channel'],
|
|
queryFn: () => api.get('/channel'),
|
|
})
|
|
|
|
const weekdayRows = (weekdayData as any)?.data || []
|
|
const hourlyRows = (hourlyData as any)?.data || []
|
|
const channelRows = (channelData as any)?.data || []
|
|
|
|
const pageLoading = weekdayLoading || hourlyLoading || channelLoading
|
|
|
|
const peakWeekday = weekdayRows.length > 0
|
|
? weekdayRows.reduce((best: any, r: any) => Number(r.received || 0) > Number(best?.received || 0) ? r : best, null)
|
|
: null
|
|
const lowWeekday = weekdayRows.length > 0
|
|
? weekdayRows.reduce((low: any, r: any) => Number(r.received || 0) < Number(low?.received || Infinity) ? r : low, null)
|
|
: null
|
|
const peakHour = hourlyRows.length > 0
|
|
? hourlyRows.reduce((best: any, r: any) => Number(r.bill_count || 0) > Number(best?.bill_count || 0) ? r : best, null)
|
|
: null
|
|
|
|
const maxBillCount = hourlyRows.length > 0 ? Math.max(...hourlyRows.map((r: any) => Number(r.bill_count || 0))) : 0
|
|
const maxReceived = hourlyRows.length > 0 ? Math.max(...hourlyRows.map((r: any) => Number(r.received || 0))) : 0
|
|
const yLeftMax = Math.ceil(maxReceived * 1.15 / 10000) * 10000
|
|
const yRightMax = Math.ceil(maxBillCount * 1.15 / 10000) * 10000
|
|
|
|
const channelTotals = channelRows.length > 0
|
|
? Object.keys(channelRows[0]).filter(k => k !== 'business_date' && k !== 'meituan_commission' && k !== 'taobao_commission' && k !== 'jd_commission')
|
|
.map(k => ({
|
|
channel: k,
|
|
total: channelRows.reduce((s: number, r: any) => s + Number(r[k] || 0), 0),
|
|
}))
|
|
.filter(c => c.total > 0)
|
|
.sort((a, b) => b.total - a.total)
|
|
: []
|
|
|
|
if (pageLoading) {
|
|
return <LoadingSpinner text="加载时间数据..." />
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h1 className="text-xl font-bold">时间维度分析</h1>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">星期规律 · 小时分布 · 渠道趋势 · 2026年4月</p>
|
|
</div>
|
|
|
|
{/* 概览指标 */}
|
|
<CollapsibleSection title="时间概览" subtitle="高峰与低谷识别">
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<MetricCard title="高峰日" value={peakWeekday?.weekday || '-'} description={`实收最高: ${formatCurrency(peakWeekday?.received)}`} />
|
|
<MetricCard title="低谷日" value={lowWeekday?.weekday || '-'} description={`实收最低: ${formatCurrency(lowWeekday?.received)}`} />
|
|
<MetricCard title="高峰时段" value={peakHour ? `${peakHour.closing_hour}:00` : '-'} description={`账单数最多: ${formatNumber(peakHour?.bill_count)}`} />
|
|
<MetricCard title="渠道数" value={channelTotals.length} format="number" description="有交易记录的支付渠道数量" />
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* 星期趋势 */}
|
|
<CollapsibleSection title="星期维度分析" subtitle="各星期实收、账单数、客单价对比">
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<BarChart data={weekdayRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="weekday" tick={{ fontSize: 12 }} />
|
|
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
|
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
|
<Tooltip formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : name === '客单价' ? formatCurrency(v) : formatNumber(v)} />
|
|
<Legend />
|
|
<Bar yAxisId="left" dataKey="received" fill="#3b82f6" name="实收" />
|
|
<Bar yAxisId="right" dataKey="bill_count" fill="#22c55e" name="账单数" />
|
|
<Bar yAxisId="left" dataKey="avg_bill_value" fill="#eab308" name="客单价" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
|
|
{/* 小时分布 */}
|
|
<CollapsibleSection title="小时维度分析" subtitle="各时段账单数与实收分布">
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<AreaChart data={hourlyRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="closing_hour" tickFormatter={(v) => `${v}:00`} tick={{ fontSize: 10 }} />
|
|
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} domain={[0, yLeftMax]} />
|
|
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} domain={[0, yRightMax]} />
|
|
<Tooltip labelFormatter={(v) => `${v}:00`} formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : formatNumber(v)} />
|
|
<Legend />
|
|
<Area yAxisId="left" type="monotone" dataKey="received" stroke="#3b82f6" fill="#3b82f680" name="实收" />
|
|
<Area yAxisId="right" type="monotone" dataKey="bill_count" stroke="#22c55e" fill="#22c55e80" name="账单数" />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
|
|
{/* 渠道趋势 */}
|
|
<CollapsibleSection title="支付渠道趋势" subtitle="各支付方式按日期变化">
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<LineChart data={channelRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
|
|
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
|
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} />
|
|
<Legend />
|
|
{channelTotals.slice(0, 6).map(c => (
|
|
<Line key={c.channel} type="monotone" dataKey={c.channel} name={channelLabel(c.channel)} stroke={CHANNEL_COLORS[c.channel] || '#gray'} dot={false} strokeWidth={1.5} />
|
|
))}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
|
|
{/* 渠道汇总 */}
|
|
<CollapsibleSection title="渠道汇总" subtitle="各支付方式总金额" defaultOpen={false}>
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
{channelTotals.map(c => (
|
|
<div key={c.channel} className="rounded-md border p-3">
|
|
<p className="text-xs text-muted-foreground">{channelLabel(c.channel)}</p>
|
|
<p className="mt-1 text-lg font-bold" style={{ color: CHANNEL_COLORS[c.channel] || '#gray' }}>
|
|
{formatCurrency(c.total)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
)
|
|
}
|