diff --git a/client/src/pages/SituationalAwarenessPage.tsx b/client/src/pages/SituationalAwarenessPage.tsx index 226cb91..0d079c0 100644 --- a/client/src/pages/SituationalAwarenessPage.tsx +++ b/client/src/pages/SituationalAwarenessPage.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' -import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, Legend, Cell } from 'recharts' +import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, Legend, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis } from 'recharts' import api from '@/lib/api' import { CollapsibleSection } from '@/components/CollapsibleSection' import { DataTable } from '@/components/DataTable' @@ -94,6 +94,7 @@ export function SituationalAwarenessPage() { // ============ Tab 1: 门店健康度综合评分 ============ function HealthScoreTab({ navigate, month }: { navigate: (path: string) => void; month: string }) { + const [selectedStore, setSelectedStore] = useState(null) const { data, isLoading } = useQuery({ queryKey: ['sa-health-score', month], queryFn: () => api.get('/situational-awareness/health-score', { params: { month } }), @@ -179,7 +180,7 @@ function HealthScoreTab({ navigate, month }: { navigate: (path: string) => void; { value: '亚健康', label: '亚健康' }, { value: '需干预', label: '需干预' }, ]} - onRowClick={(r) => navigate(`/stores/${r.store_code}`)} + onRowClick={(r) => setSelectedStore(r)} columns={[ { key: 'store_name', label: '门店' }, { key: 'health_score', label: '健康分', align: 'center', render: (r) => ( @@ -202,6 +203,126 @@ function HealthScoreTab({ navigate, month }: { navigate: (path: string) => void; ]} /> + + {selectedStore && setSelectedStore(null)} navigate={navigate} />} + + ) +} + +// ============ 健康度评分明细弹窗 ============ + +const SCORE_DIMENSIONS = [ + { key: 'score_revenue', label: '营收规模', max: 20, desc: '日均实收' }, + { key: 'score_cost', label: '成本控制', max: 20, desc: '成本差异率' }, + { key: 'score_margin', label: '毛利率', max: 15, desc: '理论毛利率' }, + { key: 'score_risk', label: '风险等级', max: 10, desc: '风险评级' }, + { key: 'score_repeat', label: '复购率', max: 10, desc: '会员复购率' }, + { key: 'score_member', label: '会员占比', max: 10, desc: '会员账单占比' }, + { key: 'score_task', label: '任务完成', max: 10, desc: '任务完成率' }, + { key: 'score_bill', label: '客单价', max: 5, desc: '平均客单价' }, +] + +function HealthScoreDetail({ store, onClose, navigate }: { store: any; onClose: () => void; navigate: (path: string) => void }) { + const radarData = SCORE_DIMENSIONS.map(d => ({ + dimension: d.label, + score: Number(store[d.key] || 0), + fullMark: d.max, + })) + + const healthColor = Number(store.health_score) >= 75 ? '#22c55e' : Number(store.health_score) >= 55 ? '#eab308' : '#ef4444' + + return ( +
+
e.stopPropagation()}> +
+
+

{store.store_name}

+ {store.health_score} + {store.health_status} + {store.risk_level && } +
+ +
+ +
+ {/* 雷达图 */} +
+ + + + + + + + + +
+ + {/* 各维度得分 */} +
+ {SCORE_DIMENSIONS.map(d => { + const score = Number(store[d.key] || 0) + const pct = (score / d.max) * 100 + const color = pct >= 75 ? '#22c55e' : pct >= 50 ? '#eab308' : '#ef4444' + return ( +
+
+ {d.label} + {score.toFixed(1)} / {d.max} +
+
+
+
+
+ ) + })} +
+
+ + {/* 原始指标 */} +
+
+

日均实收

+

{formatCurrency(store.avg_daily_received)}

+
+
+

毛利率

+

{store.avg_margin}%

+
+
+

成本差异率

+

{store.avg_cost_variance_pct}%

+
+
+

复购率

+

{store.repeat_rate_pct}%

+
+
+

会员占比

+

{store.member_share_pct}%

+
+
+

客单价

+

{formatCurrency(store.avg_bill_value)}

+
+
+

任务完成率

+

{store.task_completion_rate}%

+
+
+

月实收

+

{formatCurrency(store.received)}

+
+
+ +
+ +
+
) } diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts index 501f397..992ec18 100644 --- a/server/src/routes/situational-awareness.ts +++ b/server/src/routes/situational-awareness.ts @@ -43,12 +43,21 @@ router.get('/health-score', async (req: AuthRequest, res) => { scored AS ( SELECT b.store_code, b.store_name, b.received, b.bill_count, b.avg_bill_value, - b.risk_level, + b.risk_level, b.avg_daily_received, COALESCE(b.theoretical_margin_pct, 0) AS avg_margin, COALESCE(c.avg_cost_variance_pct, 20) AS avg_cost_variance_pct, COALESCE(m.repeat_rate_pct, 0) AS repeat_rate_pct, COALESCE(b.member_bill_share_pct, 0) AS member_share_pct, COALESCE(t.completion_rate, 0) AS task_completion_rate, + -- 各维度得分 + (20 * LEAST(1.0, b.avg_daily_received / 30000))::numeric(5,1) AS score_revenue, + (20 * GREATEST(0, 1 - LEAST(COALESCE(c.avg_cost_variance_pct, 20), 50) / 50))::numeric(5,1) AS score_cost, + (15 * LEAST(1.0, GREATEST(0, COALESCE(b.theoretical_margin_pct, 0) - 60) / 15))::numeric(5,1) AS score_margin, + (CASE b.risk_level WHEN '绿色' THEN 10 WHEN '黄色' THEN 6 WHEN '红色' THEN 2 ELSE 4 END)::numeric(5,1) AS score_risk, + (10 * LEAST(1.0, COALESCE(m.repeat_rate_pct, 0) / 40))::numeric(5,1) AS score_repeat, + (10 * LEAST(1.0, COALESCE(b.member_bill_share_pct, 0) / 20))::numeric(5,1) AS score_member, + (10 * COALESCE(t.completion_rate, 0) / 100)::numeric(5,1) AS score_task, + (5 * LEAST(1.0, COALESCE(b.avg_bill_value, 0) / 40))::numeric(5,1) AS score_bill, LEAST(100, GREATEST(0, -- 1. 营收规模 20分:30000=满分,中位18642≈12分 20 * LEAST(1.0, b.avg_daily_received / 30000) @@ -75,6 +84,9 @@ router.get('/health-score', async (req: AuthRequest, res) => { SELECT store_code, store_name, received, bill_count, avg_bill_value, risk_level, avg_margin, avg_cost_variance_pct, repeat_rate_pct, member_share_pct, task_completion_rate, health_score, + score_revenue, score_cost, score_margin, score_risk, + score_repeat, score_member, score_task, score_bill, + avg_daily_received, CASE WHEN health_score >= 75 THEN '健康' WHEN health_score >= 55 THEN '亚健康' ELSE '需干预'