Files
SBrainCO/client/src/pages/MemberPage.tsx
T
freedakgmail 4b3a48976d feat: 全部页面添加 MonthPicker 月份切换
新增 MonthPicker 的页面(6个):
- CostPage: 成本与库存管理
- SKUPage: 商品SKU管理
- PlatformPage: 平台与优惠管理
- MemberPage: 会员与复购管理
- SiteSelectionPage: 门店选址分析
- DataQualityPage: 数据质量看板

容器+子组件添加 MonthPicker(2个页面, 11个子组件):
- StoreExpensePage: 门店营业费用分析(10个子组件全部传month)
- CostAnalysisPage/StoreTab: 门店成本tab

跳过(后端不支持month参数):
- RiskPage: 风险管理(全量视图, 不按月过滤)
- TimePage: 时间段分析(全量视图, 不按月过滤)
2026-08-01 14:58:12 +08:00

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>
)
}