feat: 态势感知健康度评分明细弹窗(雷达图+8维度得分)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 '需干预'
|
||||
|
||||
Reference in New Issue
Block a user