feat: 态势感知健康度评分明细弹窗(雷达图+8维度得分)

This commit is contained in:
freedakgmail
2026-08-01 15:14:13 +08:00
parent d1f07a0e93
commit 0f6ff59d7d
2 changed files with 136 additions and 3 deletions
+123 -2
View File
@@ -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<any>(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;
]}
/>
</CollapsibleSection>
{selectedStore && <HealthScoreDetail store={selectedStore} onClose={() => setSelectedStore(null)} navigate={navigate} />}
</div>
)
}
// ============ 健康度评分明细弹窗 ============
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold">{store.store_name}</h2>
<span className="text-2xl font-bold" style={{ color: healthColor }}>{store.health_score}</span>
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
store.health_status === '健康' ? 'bg-green-100 text-green-700 border-green-300' :
store.health_status === '亚健康' ? 'bg-yellow-100 text-yellow-700 border-yellow-300' :
'bg-red-100 text-red-700 border-red-300')
}>{store.health_status}</span>
{store.risk_level && <Badge type="risk" text={store.risk_level} />}
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl"></button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{/* 雷达图 */}
<div>
<ResponsiveContainer width="100%" height={250}>
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
<PolarRadiusAxis angle={90} domain={[0, 20]} tick={{ fontSize: 8 }} />
<Radar dataKey="score" stroke={healthColor} fill={healthColor} fillOpacity={0.3} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</div>
{/* 各维度得分 */}
<div className="space-y-2">
{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 (
<div key={d.key}>
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{d.label}</span>
<span className="text-muted-foreground">{score.toFixed(1)} / {d.max}</span>
</div>
<div className="mt-0.5 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
)
})}
</div>
</div>
{/* 原始指标 */}
<div className="mt-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{formatCurrency(store.avg_daily_received)}</p>
</div>
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{store.avg_margin}%</p>
</div>
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{store.avg_cost_variance_pct}%</p>
</div>
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{store.repeat_rate_pct}%</p>
</div>
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{store.member_share_pct}%</p>
</div>
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{formatCurrency(store.avg_bill_value)}</p>
</div>
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{store.task_completion_rate}%</p>
</div>
<div className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold">{formatCurrency(store.received)}</p>
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={() => navigate(`/stores/${store.store_code}`)} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"></button>
</div>
</div>
</div>
)
}
+13 -1
View File
@@ -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 '需干预'