import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart } from 'recharts' import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { Badge } from '@/components/Badge' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' import { useState, useMemo } from 'react' const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' } const PAGE_SIZE = 10 export function DashboardPage() { const navigate = useNavigate() const [priorityPage, setPriorityPage] = useState(1) const { data: overview, isLoading: odLoading } = useQuery({ queryKey: ['overview'], queryFn: () => api.get('/overview'), }) const { data: daily, isLoading: dailyLoading } = useQuery({ queryKey: ['overview/daily'], queryFn: () => api.get('/overview/daily'), }) const { data: riskData, isLoading: riskLoading } = useQuery({ queryKey: ['stores/risk'], queryFn: () => api.get('/stores/risk'), }) const { data: priorityData, isLoading: priorityLoading } = useQuery({ queryKey: ['stores/priority'], queryFn: () => api.get('/stores/priority'), }) const { data: loopHealth, isLoading: loopLoading } = useQuery({ queryKey: ['loop-health'], queryFn: () => api.get('/tasks/loop-health'), }) const { data: quadrantData } = useQuery({ queryKey: ['stores/quadrant'], queryFn: () => api.get('/stores/quadrant'), }) const { data: platformData } = useQuery({ queryKey: ['platform/economics'], queryFn: () => api.get('/platform/economics'), }) const { data: alertsData } = useQuery({ queryKey: ['sa-alerts'], queryFn: () => api.get('/situational-awareness/alerts'), }) const { data: expenseData } = useQuery({ queryKey: ['store-expense/overview'], queryFn: () => api.get('/store-expense/overview'), }) const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading const od = (overview as any)?.data const ex = (expenseData as any)?.data const riskRows = (riskData as any)?.data || [] const priorityRows = (priorityData as any)?.data || [] const dailyRows = (daily as any)?.data || [] const lh = (loopHealth as any)?.data const quadrantRows = (quadrantData as any)?.data || [] const platformRows = (platformData as any)?.data || [] const alerts = ((alertsData as any)?.data?.alerts || []) as any[] const alertSummary = (alertsData as any)?.data const QUADRANT_COLORS: Record = { '明星门店': '#22c55e', '稳健经营': '#3b82f6', '规模承压': '#eab308', '重点改善': '#ef4444', '高效潜力': '#a855f7', } const quadrantScatterData = quadrantRows.map((r: any) => ({ name: r.store_name, x: Number(r.avg_daily_received || 0), y: Number(r.theoretical_margin_pct || 0), quadrant: r.management_quadrant, fill: QUADRANT_COLORS[r.management_quadrant] || '#999', })) const platformCostData = platformRows.slice(0, 15).map((r: any) => ({ name: r.store_name?.substring(0, 6) || '', meituan: Number(r.meituan_cost_rate_pct || 0), eleme: Number(r.eleme_cost_rate_pct || 0), douyin: Number(r.douyin_cost_rate_pct || 0), })) const p0Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0')) const p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P1')) const p0Received = p0Stores.reduce((s: number, r: any) => s + Number(r.received || 0), 0) const p1Received = p1Stores.reduce((s: number, r: any) => s + Number(r.received || 0), 0) const otherReceived = od?.received ? Number(od.received) - p0Received - p1Received : 0 const waterfallData = [ { name: 'P0门店', value: p0Received, fill: '#ef4444' }, { name: 'P1门店', value: p1Received, fill: '#eab308' }, { name: '其他门店', value: otherReceived, fill: '#22c55e' }, { name: '合计', value: Number(od?.received || 0), fill: '#3b82f6' }, ] const riskSummary = riskRows.reduce((acc: any, r: any) => { acc[r.risk_level] = (acc[r.risk_level] || 0) + 1 return acc }, {}) const totalStores = riskRows.length const riskPieData = Object.entries(riskSummary).map(([name, value]) => ({ name, value: value as number, pct: totalStores > 0 ? Math.round((value as number / totalStores) * 100) : 0, })) const prioritySummary = priorityRows.reduce((acc: any, r: any) => { const key = r.action_priority acc[key] = (acc[key] || 0) + 1 return acc }, {}) const p0p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1')) const pagedP0P1 = useMemo(() => p0p1Stores.slice((priorityPage - 1) * PAGE_SIZE, priorityPage * PAGE_SIZE), [p0p1Stores, priorityPage]) // 闭环健康度综合得分 const loopScore = lh ? ( (parseFloat(lh.task_generation_rate) + parseFloat(lh.store_execution_rate) + parseFloat(lh.weekly_check_rate) + parseFloat(lh.monthly_review_rate) + parseFloat(lh.practice_promotion_rate)) / 5 ) : 0 // 计算环比趋势:最后7天 vs 前7天 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 trendAvgBill = trend( last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0, prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0, ) if (pageLoading) { return } return (

总部驾驶舱

全部门店经营总览 · 2026年4月

{totalStores} 家门店 红色 {riskSummary['红色'] || 0} 黄色 {riskSummary['黄色'] || 0} 绿色 {riskSummary['绿色'] || 0}
{/* 经营指标 */}
{/* 运营指标 */}
{/* 利润与成本 */} {ex && (
)} {/* 闭环健康度 */} {lh && ( 综合得分 = 80 ? 'text-green-600' : loopScore >= 60 ? 'text-yellow-600' : 'text-red-600'}`}> {loopScore.toFixed(1)}%
} >
{[ { label: '任务生成率', value: lh.task_generation_rate, desc: '系统自动生成的整改任务占应生成任务的比例' }, { label: '店长执行率', value: lh.store_execution_rate, desc: '店长已提交反馈的任务占分配任务的比例' }, { label: '区域周检率', value: lh.weekly_check_rate, desc: '区域经理完成周度检查的比例' }, { label: '月度验收率', value: lh.monthly_review_rate, desc: '月度完成验收的任务比例' }, { label: '经验推广率', value: lh.practice_promotion_rate, desc: '达标经验被推广到其他门店的比例' }, ].map((item) => { const num = parseFloat(item.value) const isLow = num < 60 const isMid = num >= 60 && num < 80 return (

{item.label}

?

{num.toFixed(1)}%

) })}
)} {/* 态势感知预警 */} {alerts.length > 0 && ( {alertSummary?.red > 0 && 红色 {alertSummary.red}} {alertSummary?.orange > 0 && 橙色 {alertSummary.orange}} 共 {alertSummary?.total || 0} 条
} >
{alerts.slice(0, 6).map((a, i) => (
{a.level === 'red' ? '红' : a.level === 'orange' ? '橙' : '黄'}

{a.title}

{a.detail}

))}
)} {/* 图表区 */}
v?.substring(5, 10)} tick={{ fontSize: 10 }} /> v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} /> `${name} ${value}家 (${pct}%)`} > {riskPieData.map((entry) => ( ))}
{/* 第二行图表 */}
{/* 象限散点图 */} `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 10 }} /> n === '毛利率' ? `${Number(v).toFixed(1)}%` : n === '日均实收' ? `¥${Number(v).toFixed(0)}` : v} /> {Object.entries(QUADRANT_COLORS).map(([name, color]) => ( d.quadrant === name)} fill={color} /> ))} {/* P0/P1实收覆盖瀑布图 */} v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> formatCurrency(v)} /> {waterfallData.map((entry, idx) => )} {/* 平台成本率柱状图 */}
{/* P0/P1 门店列表 */} {Object.entries(prioritySummary).filter(([k]) => k?.startsWith('P0') || k?.startsWith('P1')).map(([k, v]) => ( {k}: {v as number}家 ))} } >
}, { key: 'problem_count', label: '问题数', align: 'center' }, { key: 'problem_combination', label: '问题组合' }, { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, ]} data={pagedP0P1} onRowClick={(r) => navigate(`/stores/${r.store_code}`)} />
) }