From 521ba889376163cf3ba216c591db9c11c3685e4a Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sun, 26 Jul 2026 23:16:52 +0800 Subject: [PATCH] =?UTF-8?q?Phase=206-11:=20=E9=A9=BE=E9=A9=B6=E8=88=B1?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8/=E5=8C=BA=E5=9F=9F=E7=BB=8F=E7=90=86?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E5=AF=B9=E6=AF=94+=E5=91=A8=E6=A3=80?= =?UTF-8?q?=E5=BD=95=E5=85=A5/=E5=BA=97=E9=95=BFSKU=E5=A4=87=E8=B4=A7+?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E8=BF=9B=E5=BA=A6/=E9=97=A8=E5=BA=97?= =?UTF-8?q?=E8=AF=A6=E6=83=857Tab/=E6=9C=88=E5=BA=A6=E9=AA=8C=E6=94=B67Tab?= =?UTF-8?q?/=E5=8C=BA=E5=9F=9F=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 6: 驾驶舱新增象限散点图、P0/P1实收覆盖瀑布图、平台成本率TOP15柱状图 - Phase 7: 区域经理页面新增同类门店基准对比表、周检录入弹窗 - Phase 8: 店长页面新增核心SKU备货提醒、本周指标进度条,经营概览与进度并排 - Phase 9: 门店详情页7Tab(概览/餐段/品类/成本/会员/异常/任务),5个新API - Phase 10: 月度验收7Tab(验收汇总/升降级/完成率/活动清单/SKU治理/经验标准化/复盘优化),4个新API+阈值调整API - Phase 11: 前后端编译通过,API测试通过,浏览器验证0 error - 门店区域划分:从store_location_master填充dim_store.region(87家),v_region_summary视图+API+前端表格 - MetricCard增加text格式支持 --- client/src/components/MetricCard.tsx | 4 +- client/src/pages/DashboardPage.tsx | 96 +++++++- client/src/pages/MonthlyReviewPage.tsx | 327 +++++++++++++++++++++---- client/src/pages/RegionalPage.tsx | 108 +++++++- client/src/pages/StoreDetailPage.tsx | 325 +++++++++++++++++++++--- client/src/pages/StorePage.tsx | 111 +++++++-- server/src/routes/data.ts | 69 ++++++ server/src/routes/tasks.ts | 103 ++++++++ task.md | 33 ++- 9 files changed, 1069 insertions(+), 107 deletions(-) diff --git a/client/src/components/MetricCard.tsx b/client/src/components/MetricCard.tsx index 1d477db..5987793 100644 --- a/client/src/components/MetricCard.tsx +++ b/client/src/components/MetricCard.tsx @@ -4,7 +4,7 @@ interface MetricCardProps { title: string value: string | number | null | undefined unit?: string - format?: 'number' | 'percent' | 'currency' + format?: 'number' | 'percent' | 'currency' | 'text' trend?: number description?: string className?: string @@ -15,6 +15,8 @@ export function MetricCard({ title, value, unit, format = 'number', trend, descr ? value === null || value === undefined ? '-' : `${Number(value).toFixed(2)}%` : format === 'currency' ? value === null || value === undefined ? '-' : `¥${formatNumber(value)}` + : format === 'text' + ? (value || '-') : formatNumber(value) return ( diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 3bb4642..a581982 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -1,6 +1,6 @@ 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 } from 'recharts' +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' @@ -43,6 +43,16 @@ export function DashboardPage() { 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 pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading const od = (overview as any)?.data @@ -50,6 +60,39 @@ export function DashboardPage() { 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 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 @@ -214,6 +257,57 @@ export function DashboardPage() { + {/* 第二行图表 */} +
+ {/* 象限散点图 */} + + + + + `${(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 门店列表 */} ('summary') const { data } = useQuery({ queryKey: ['monthly-review', month], queryFn: () => api.get('/tasks/monthly-review', { params: { month } }), }) + const { data: gradeData } = useQuery({ + queryKey: ['grade-change'], + queryFn: () => api.get('/tasks/grade-change'), + enabled: tab === 'grade-change', + }) + + const { data: completionData } = useQuery({ + queryKey: ['monthly-review/completion', month], + queryFn: () => api.get('/tasks/monthly-review/completion', { params: { month } }), + enabled: tab === 'completion', + }) + + const { data: activityData } = useQuery({ + queryKey: ['monthly-review/activity-list'], + queryFn: () => api.get('/tasks/monthly-review/activity-list'), + enabled: tab === 'activity', + }) + + const { data: skuGovData } = useQuery({ + queryKey: ['monthly-review/sku-governance'], + queryFn: () => api.get('/tasks/monthly-review/sku-governance'), + enabled: tab === 'sku', + }) + + const { data: effectivenessData } = useQuery({ + queryKey: ['monthly-review/indicator-effectiveness', month], + queryFn: () => api.get('/tasks/monthly-review/indicator-effectiveness', { params: { month } }), + enabled: tab === 'effectiveness', + }) + const rd = (data as any)?.data const summary = rd?.summary || { total: 0, passed: 0, improving: 0, failed: 0 } const details = rd?.details || [] const pagedDetails = useMemo(() => details.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [details, page]) + const gradeRows = (gradeData as any)?.data || [] + const completionRows = (completionData as any)?.data || [] + const activityRows = (activityData as any)?.data || [] + const skuGov = (skuGovData as any)?.data + const effectivenessRows = (effectivenessData as any)?.data || [] + const pieData = [ { name: '达标', value: summary.passed }, { name: '改善中', value: summary.improving }, @@ -33,7 +83,7 @@ export function MonthlyReviewPage() { return (
-

月度验收

+

月度验收与复盘

- {/* 汇总卡片 */} -
- - - - + {/* Tab bar */} +
+ {REVIEW_TABS.map(t => ( + + ))}
-
- {/* 饼图 */} -
-

验收结果分布

- {pieData.length > 0 ? ( - - - `${name}: ${value}`}> - {pieData.map((d) => )} - - - - - ) :
暂无验收数据
} -
+ {/* 验收汇总 Tab */} + {tab === 'summary' && ( + <> +
+ + + + +
+
+
+

验收结果分布

+ {pieData.length > 0 ? ( + + + `${name}: ${value}`}> + {pieData.map((d) => )} + + + + + ) :
暂无验收数据
} +
+
+
+

验收明细

+ +
+ }, + ]} + data={pagedDetails} + /> +
+
+ + )} - {/* 经验标准化 */} + {/* 升降级面板 Tab */} + {tab === 'grade-change' && ( +
+

门店P0/P1/P2升降级面板

+ {gradeRows.length === 0 ?

暂无升降级数据

: ( + r.change_month?.substring(0, 7) }, + { key: 'store_name', label: '门店' }, + { key: 'old_priority', label: '原级别', render: (r) => }, + { key: 'new_priority', label: '新级别', render: (r) => }, + { key: 'change_type', label: '变化', render: (r) => { + const isUpgrade = ['P0→P1', 'P1→P2', 'P0→P2'].includes(r.change_type) + return {isUpgrade ? '↑ 升级' : '↓ 降级'} + } }, + { key: 'reason', label: '原因', render: (r) => {r.reason} }, + ]} + data={gradeRows} + /> + )} +
+ )} + + {/* 任务完成率 Tab */} + {tab === 'completion' && ( +
+

任务完成率(按优先级汇总)

+ {completionRows.length === 0 ?

暂无数据

: ( + <> +
+ {completionRows.map((c: any) => ( +
+

{c.priority}

+

{c.completion_rate}%

+

{c.completed}/{c.total} 已完成

+
+
= 80 ? 'bg-green-500' : Number(c.completion_rate) >= 60 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(Number(c.completion_rate), 100)}%` }} /> +
+
+ ))} +
+ }, + { key: 'total', label: '总数', align: 'right' }, + { key: 'completed', label: '已完成', align: 'right' }, + { key: 'passed', label: '达标', align: 'right', render: (r) => {r.passed} }, + { key: 'improving', label: '改善中', align: 'right', render: (r) => {r.improving} }, + { key: 'failed', label: '未改善', align: 'right', render: (r) => {r.failed} }, + { key: 'completion_rate', label: '完成率', align: 'right', render: (r) => `${r.completion_rate}%` }, + ]} + data={completionRows} + /> + + )} +
+ )} + + {/* 活动清单 Tab */} + {tab === 'activity' && ( +
+

平台活动停/改/留清单

+

按活动贡献毛利排序,建议保留高毛利活动、停改低毛利活动

+ {activityRows.length === 0 ?

暂无数据

: ( + formatNumber(r.bill_count) }, + { key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) }, + { key: 'discounts', label: '优惠额', align: 'right', render: (r) => formatCurrency(r.discounts) }, + { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, + { key: 'discount_cost_rate', label: '优惠成本率', align: 'right', render: (r) => formatPercent(r.discount_cost_rate) }, + { key: 'net_contribution', label: '净贡献', align: 'right', render: (r) => formatCurrency(r.net_contribution) }, + { key: 'action', label: '建议', render: (r) => { + const rate = Number(r.discount_cost_rate || 0) + const net = Number(r.net_contribution || 0) + if (net > 0 && rate < 15) return + if (rate > 30) return + return + } }, + ]} + data={activityRows} + /> + )} +
+ )} + + {/* SKU治理 Tab */} + {tab === 'sku' && ( +
+ {skuGov?.summary && ( +
+ {skuGov.summary.map((s: any) => ( +
+

{s.abc_class}

+

{s.sku_count}

+

营收占比: {s.total_revenue_share}%

+
+ ))} +
+ )} +
+

长尾SKU治理清单 (C类)

+ {skuGov?.longtail?.length > 0 ? ( + formatNumber(r.bill_count) }, + { key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) }, + { key: 'revenue_share_pct', label: '营收占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` }, + ]} + data={skuGov.longtail.slice(0, 20)} + /> + ) :

暂无长尾SKU数据

} +
+
+ )} + + {/* 经验标准化 Tab */} + {tab === 'practice' && (

经验标准化

达标门店的有效经验可标准化推广

-
+ )} - {/* 明细表 */} -
-

验收明细

- -
- }, - ]} - data={pagedDetails} - /> + {/* 复盘优化 Tab */} + {tab === 'effectiveness' && ( +
+

指标有效性评价

+

评估上月哪些指标真正驱动了改善,哪些没有区分力

+ {effectivenessRows.length === 0 ?

暂无数据

: ( + {r.passed} }, + { key: 'improving', label: '改善中', align: 'right', render: (r) => {r.improving} }, + { key: 'failed', label: '未改善', align: 'right', render: (r) => {r.failed} }, + { key: 'pass_rate', label: '达标率', align: 'right', render: (r) => { + const v = Number(r.pass_rate || 0) + return = 50 ? 'text-green-600' : v >= 30 ? 'text-yellow-600' : 'text-red-600'}>{v}% + } }, + { key: 'assessment', label: '评价', render: (r) => { + const v = Number(r.pass_rate || 0) + if (v >= 50) return + if (v >= 30) return + return + } }, + ]} + data={effectivenessRows} + /> + )}
-
+ )}
) } function PracticeSection() { + const queryClient = useQueryClient() + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ practice_module: '', benchmark_store_name: '', key_actions: '', success_metrics: '' }) + const { data } = useQuery({ queryKey: ['practices'], queryFn: () => api.get('/tasks/practices'), @@ -105,8 +319,31 @@ function PracticeSection() { const practices = (data as any)?.data || [] + const createMutation = useMutation({ + mutationFn: (data: any) => api.post('/tasks/practices', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['practices'] }) + setShowForm(false) + setForm({ practice_module: '', benchmark_store_name: '', key_actions: '', success_metrics: '' }) + }, + }) + return ( -
+
+ + {showForm && ( +
+ setForm({ ...form, practice_module: e.target.value })} className="w-full rounded-md border p-2 text-sm" /> + setForm({ ...form, benchmark_store_name: e.target.value })} className="w-full rounded-md border p-2 text-sm" /> +