4b3a48976d
新增 MonthPicker 的页面(6个): - CostPage: 成本与库存管理 - SKUPage: 商品SKU管理 - PlatformPage: 平台与优惠管理 - MemberPage: 会员与复购管理 - SiteSelectionPage: 门店选址分析 - DataQualityPage: 数据质量看板 容器+子组件添加 MonthPicker(2个页面, 11个子组件): - StoreExpensePage: 门店营业费用分析(10个子组件全部传month) - CostAnalysisPage/StoreTab: 门店成本tab 跳过(后端不支持month参数): - RiskPage: 风险管理(全量视图, 不按月过滤) - TimePage: 时间段分析(全量视图, 不按月过滤)
159 lines
8.2 KiB
TypeScript
159 lines
8.2 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import api from '@/lib/api'
|
|
import { DataTable } from '@/components/DataTable'
|
|
import { Pagination } from '@/components/Pagination'
|
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
|
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
|
import { MetricCard } from '@/components/MetricCard'
|
|
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
|
import { useState, useMemo } from 'react'
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
|
|
import { MonthPicker } from '@/components/MonthPicker'
|
|
|
|
const PAGE_SIZE = 15
|
|
|
|
export function MemberPage() {
|
|
const [repeatPage, setRepeatPage] = useState(1)
|
|
const [month, setMonth] = useState('2026-04')
|
|
|
|
const { data: comparisonData, isLoading: cmpLoading } = useQuery({
|
|
queryKey: ['member/comparison'],
|
|
queryFn: () => api.get('/member/comparison'),
|
|
})
|
|
|
|
const { data: repeatData, isLoading: repeatLoading } = useQuery({
|
|
queryKey: ['member/repeat', month],
|
|
queryFn: () => api.get('/member/repeat', { params: { month } }),
|
|
})
|
|
|
|
const cmpRows = (comparisonData as any)?.data || []
|
|
const repeatRows = (repeatData as any)?.data || []
|
|
|
|
const pageLoading = cmpLoading || repeatLoading
|
|
|
|
const memberRow = cmpRows.find((r: any) => r.customer_type === '会员') || {}
|
|
const nonMemberRow = cmpRows.find((r: any) => r.customer_type === '非会员') || {}
|
|
|
|
const totalBills = Number(memberRow.bill_count || 0) + Number(nonMemberRow.bill_count || 0)
|
|
const memberBillShare = totalBills > 0 ? (Number(memberRow.bill_count || 0) / totalBills * 100) : 0
|
|
const totalReceived = Number(memberRow.received || 0) + Number(nonMemberRow.received || 0)
|
|
const memberRevenueShare = totalReceived > 0 ? (Number(memberRow.received || 0) / totalReceived * 100) : 0
|
|
|
|
const avgRepeatRate = repeatRows.length > 0
|
|
? repeatRows.reduce((s: number, r: any) => s + Number(r.repeat_rate_pct || 0), 0) / repeatRows.length
|
|
: 0
|
|
const topRepeatStore = repeatRows.length > 0
|
|
? repeatRows.reduce((best: any, r: any) => Number(r.repeat_rate_pct || 0) > Number(best?.repeat_rate_pct || 0) ? r : best, null)
|
|
: null
|
|
const lowRepeatCount = repeatRows.filter((r: any) => Number(r.repeat_rate_pct || 0) < 30).length
|
|
|
|
const cmpChartData = cmpRows.map((r: any) => ({
|
|
name: r.customer_type,
|
|
账单数: Number(r.bill_count || 0),
|
|
实收: Number(r.received || 0),
|
|
客单价: Number(r.avg_bill_value || 0),
|
|
}))
|
|
|
|
const pieData = [
|
|
{ name: '会员账单', value: Number(memberRow.bill_count || 0) },
|
|
{ name: '非会员账单', value: Number(nonMemberRow.bill_count || 0) },
|
|
]
|
|
const PIE_COLORS = ['#3b82f6', '#e5e7eb']
|
|
|
|
const pagedRepeat = useMemo(() => repeatRows.slice((repeatPage - 1) * PAGE_SIZE, repeatPage * PAGE_SIZE), [repeatRows, repeatPage])
|
|
|
|
if (pageLoading) {
|
|
return <LoadingSpinner text="加载会员数据..." />
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold">会员与复购管理</h1>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">会员对比 · 复购率 · 沉睡分析 · {month}</p>
|
|
</div>
|
|
<MonthPicker month={month} onChange={setMonth} />
|
|
</div>
|
|
|
|
{/* 概览指标 */}
|
|
<CollapsibleSection title="会员概览" subtitle="会员渗透与消费对比">
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<MetricCard title="会员账单占比" value={memberBillShare} format="percent" description="会员账单数占总账单数的比例" />
|
|
<MetricCard title="会员收入占比" value={memberRevenueShare} format="percent" description="会员实收金额占总实收的比例" />
|
|
<MetricCard title="平均复购率" value={avgRepeatRate} format="percent" description="所有门店30日复购率的平均值" />
|
|
<MetricCard title="低复购门店(<30%)" value={lowRepeatCount} format="number" description="复购率低于30%的门店数,需重点改善" />
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* 会员对比图 */}
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<CollapsibleSection title="会员 vs 非会员消费对比" subtitle="账单数、实收、客单价对比">
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<BarChart data={cmpChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
|
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
|
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
|
<Tooltip formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : name === '客单价' ? formatCurrency(v) : formatNumber(v)} />
|
|
<Legend />
|
|
<Bar yAxisId="left" dataKey="账单数" fill="#3b82f6" />
|
|
<Bar yAxisId="left" dataKey="实收" fill="#22c55e" />
|
|
<Bar yAxisId="right" dataKey="客单价" fill="#eab308" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection title="会员账单占比" subtitle="会员与非会员账单分布">
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<PieChart>
|
|
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
|
|
label={({ name, value, }: any) => `${name}: ${formatNumber(value)}`}>
|
|
{pieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i]} />)}
|
|
</Pie>
|
|
<Tooltip formatter={(v: any) => formatNumber(v)} />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
</div>
|
|
|
|
{/* 会员对比明细 */}
|
|
<CollapsibleSection title="会员与非会员指标对比" subtitle="优惠率、毛利率等关键指标">
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'customer_type', label: '客户类型' },
|
|
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
|
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
|
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
|
|
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
|
|
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
|
|
]}
|
|
data={cmpRows}
|
|
/>
|
|
</CollapsibleSection>
|
|
|
|
{/* 门店复购率 */}
|
|
<CollapsibleSection title={`门店复购率 (${repeatRows.length})`} subtitle="按复购率降序,低于30%需重点改善">
|
|
<Pagination page={repeatPage} pageSize={PAGE_SIZE} total={repeatRows.length} onPageChange={setRepeatPage} />
|
|
<div className="mt-3">
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'store_name', label: '门店' },
|
|
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
|
|
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
|
|
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => {
|
|
const v = Number(r.repeat_rate_pct || 0)
|
|
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
|
|
}},
|
|
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
|
|
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
|
|
]}
|
|
data={pagedRepeat}
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
)
|
|
}
|