feat: 门店详情页添加健康度评分(标题旁分数+状态, 概览Tab雷达图+得分条)

This commit is contained in:
freedakgmail
2026-08-01 15:38:01 +08:00
parent 0f6ff59d7d
commit ac8e39e2f9
2 changed files with 69 additions and 4 deletions
@@ -225,8 +225,8 @@ const SCORE_DIMENSIONS = [
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,
score: Math.round(Number(store[d.key] || 0) / d.max * 100),
fullMark: 100,
}))
const healthColor = Number(store.health_score) >= 75 ? '#22c55e' : Number(store.health_score) >= 55 ? '#eab308' : '#ef4444'
@@ -255,7 +255,7 @@ function HealthScoreDetail({ store, onClose, navigate }: { store: any; onClose:
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
<PolarRadiusAxis angle={90} domain={[0, 20]} tick={{ fontSize: 8 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
<Radar dataKey="score" stroke={healthColor} fill={healthColor} fillOpacity={0.3} />
<Tooltip />
</RadarChart>
+66 -1
View File
@@ -8,11 +8,22 @@ import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils'
import { ArrowLeft } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { useState } from 'react'
const SCORE_DIMENSIONS = [
{ key: 'score_revenue', label: '营收规模', max: 20 },
{ key: 'score_cost', label: '成本控制', max: 20 },
{ key: 'score_margin', label: '毛利率', max: 15 },
{ key: 'score_risk', label: '风险等级', max: 10 },
{ key: 'score_repeat', label: '复购率', max: 10 },
{ key: 'score_member', label: '会员占比', max: 10 },
{ key: 'score_task', label: '任务完成', max: 10 },
{ key: 'score_bill', label: '客单价', max: 5 },
]
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
const TABS: { key: DetailTab; label: string }[] = [
@@ -37,6 +48,11 @@ export function StoreDetailPage() {
queryFn: () => api.get(`/stores/${code}`),
})
const { data: healthData } = useQuery({
queryKey: ['store-health', code, month],
queryFn: () => api.get('/situational-awareness/health-score', { params: { month } }),
})
const { data: dailyData } = useQuery({
queryKey: ['store', code, 'daily'],
queryFn: () => api.get(`/stores/${code}/daily`),
@@ -80,6 +96,8 @@ export function StoreDetailPage() {
const sd = (storeData as any)?.data
const daily = ((dailyData as any)?.data) || []
const healthRows = ((healthData as any)?.data) || []
const health = healthRows.find((r: any) => r.store_code === code)
const tasks = ((tasksData as any)?.data) || []
const mealRows = ((mealData as any)?.data) || []
const cat = (catData as any)?.data
@@ -137,6 +155,18 @@ export function StoreDetailPage() {
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{health && (
<>
<span className={cn('text-lg font-bold', Number(health.health_score) >= 75 ? 'text-green-600' : Number(health.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}>
{health.health_score}
</span>
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
health.health_status === '健康' ? 'bg-green-100 text-green-700 border-green-300' :
health.health_status === '亚健康' ? 'bg-yellow-100 text-yellow-700 border-yellow-300' :
'bg-red-100 text-red-700 border-red-300')
}>{health.health_status}</span>
</>
)}
{risk?.risk_level && <Badge type="risk" text={risk.risk_level} />}
{quadrant && <span className="rounded-md bg-muted px-2 py-0.5 text-xs">{quadrant}</span>}
<MonthPicker month={month} onChange={setMonth} />
@@ -234,6 +264,41 @@ export function StoreDetailPage() {
{/* 概览 Tab */}
{tab === 'overview' && (
<div className="space-y-4">
{health && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid gap-4 md:grid-cols-2">
<ResponsiveContainer width="100%" height={250}>
<RadarChart data={SCORE_DIMENSIONS.map(d => ({ dimension: d.label, score: Math.round(Number(health[d.key] || 0) / d.max * 100), fullMark: 100 }))}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
<Radar dataKey="score" stroke={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fill={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fillOpacity={0.3} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
<div className="space-y-2">
{SCORE_DIMENSIONS.map(d => {
const score = Number(health[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>
)}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<ResponsiveContainer width="100%" height={250}>