feat: 混合模式UI优化 - 全局态势状态条 + 预警前置 + 风险分布条 + 健康度仪表盘

- 新建 StatusBanner 组件: 全局态势状态条(门店风险/预警/闭环健康度)
- Layout: 侧边栏增加Activity图标,主内容区顶部嵌入StatusBanner
- MetricCard: status增加左侧彩色竖条强化视觉层级
- DashboardPage: 预警区域前置到经营指标上方,有红色预警时默认展开
- DashboardPage: 标题下方新增风险分布条(红黄绿百分比)
- DashboardPage: 闭环健康度增加RadialBarChart仪表盘
- DashboardPage: 预警卡片增加AlertTriangle图标和左侧彩色竖条
- StatusBanner: 各指标增加title tooltip取数说明
This commit is contained in:
freedakgmail
2026-08-02 15:20:58 +08:00
parent 271ad8041d
commit 30651969ad
4 changed files with 257 additions and 67 deletions
+6 -1
View File
@@ -2,6 +2,7 @@ import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { StatusBanner } from '@/components/StatusBanner'
interface LayoutProps {
children: ReactNode
@@ -114,7 +115,8 @@ export function Layout({ children, user, onLogout }: LayoutProps) {
'fixed left-0 top-0 z-40 h-full w-56 border-r bg-card transition-transform lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}>
<div className="flex h-14 items-center border-b px-4">
<div className="flex h-14 items-center gap-2 border-b px-4">
<Activity size={22} className="text-primary" />
<span className="text-lg font-bold"></span>
</div>
<nav className="space-y-4 overflow-y-auto p-3 pb-20" style={{ maxHeight: 'calc(100vh - 3.5rem - 5rem)' }}>
@@ -164,6 +166,9 @@ export function Layout({ children, user, onLogout }: LayoutProps) {
{/* Main content */}
<div className="lg:pl-56">
<main className="min-h-screen p-4 pt-16 lg:pt-4">
<div className="mb-4">
<StatusBanner />
</div>
{children}
</main>
</div>
+3 -3
View File
@@ -12,9 +12,9 @@ interface MetricCardProps {
}
const STATUS_STYLES: Record<string, string> = {
good: 'border-green-200 bg-green-50/40',
warn: 'border-yellow-200 bg-yellow-50/40',
bad: 'border-red-200 bg-red-50/40',
good: 'border-green-200 bg-green-50/40 border-l-4 border-l-green-500',
warn: 'border-yellow-200 bg-yellow-50/40 border-l-4 border-l-yellow-500',
bad: 'border-red-200 bg-red-50/40 border-l-4 border-l-red-500',
}
const STATUS_VALUE_STYLES: Record<string, string> = {
+159
View File
@@ -0,0 +1,159 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { cn } from '@/lib/utils'
import { AlertTriangle, Activity, CheckCircle, XCircle } from 'lucide-react'
export function StatusBanner({ month }: { month?: string }) {
const navigate = useNavigate()
const m = month || '2026-04'
const { data: riskData } = useQuery({
queryKey: ['stores/risk', m],
queryFn: () => api.get('/stores/risk', { params: { month: m } }),
staleTime: 5 * 60 * 1000,
})
const { data: alertsData } = useQuery({
queryKey: ['sa-alerts', m],
queryFn: () => api.get('/situational-awareness/alerts', { params: { month: m } }),
staleTime: 5 * 60 * 1000,
})
const { data: loopHealth } = useQuery({
queryKey: ['loop-health'],
queryFn: () => api.get('/tasks/loop-health'),
staleTime: 5 * 60 * 1000,
})
const riskRows = (riskData as any)?.data || []
const alertSummary = (alertsData as any)?.data
const lh = (loopHealth as any)?.data
const red = riskRows.filter((r: any) => r.risk_level === '红色').length
const yellow = riskRows.filter((r: any) => r.risk_level === '黄色').length
const green = riskRows.filter((r: any) => r.risk_level === '绿色').length
const total = riskRows.length || 1
const alertRed = alertSummary?.red || 0
const alertOrange = alertSummary?.orange || 0
const alertTotal = alertSummary?.total || 0
const loopScore = lh ? (
(parseFloat(lh.task_generation_rate) +
parseFloat(lh.store_execution_rate) +
parseFloat(lh.weekly_check_rate) +
parseFloat(lh.monthly_review_rate) +
parseFloat(lh.practice_promotion_rate)) / 5
) : null
return (
<div className="flex items-center gap-4 rounded-lg border bg-card px-4 py-2 text-sm shadow-sm">
{/* 门店风险分布 */}
<div className="flex items-center gap-2" title="数据来源: /stores/risk · 按风险等级统计门店数量(红/黄/绿)">
<Activity size={16} className="text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground"></span>
<button
onClick={() => navigate('/')}
title="红色: 存在严重问题需紧急整改"
className="flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors hover:bg-muted"
>
<span className="h-2.5 w-2.5 rounded-full bg-red-500" />
<span className="font-bold text-red-600">{red}</span>
</button>
<button
onClick={() => navigate('/')}
title="黄色: 存在风险需关注"
className="flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors hover:bg-muted"
>
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500" />
<span className="font-bold text-yellow-600">{yellow}</span>
</button>
<button
onClick={() => navigate('/')}
title="绿色: 经营正常"
className="flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors hover:bg-muted"
>
<span className="h-2.5 w-2.5 rounded-full bg-green-500" />
<span className="font-bold text-green-600">{green}</span>
</button>
</div>
<div className="h-4 w-px bg-border" />
{/* 风险分布条 */}
<div className="hidden h-2 w-32 overflow-hidden rounded-full md:flex" title={`${red}${yellow} 绿${green} / 共${total}`}>
<div className="bg-red-500" style={{ width: `${red / total * 100}%` }} />
<div className="bg-yellow-500" style={{ width: `${yellow / total * 100}%` }} />
<div className="bg-green-500" style={{ width: `${green / total * 100}%` }} />
</div>
<div className="h-4 w-px bg-border" />
{/* 预警 */}
<button
onClick={() => navigate('/situational-awareness')}
title="数据来源: /situational-awareness/alerts · 营收异动·成本异动·人力异动·平台依赖·任务逾期,每类最多10条"
className={cn(
'flex items-center gap-1.5 rounded px-2 py-0.5 transition-colors hover:bg-muted',
alertRed > 0 && 'bg-red-50'
)}
>
<AlertTriangle size={16} className={alertRed > 0 ? 'text-red-600' : alertOrange > 0 ? 'text-orange-600' : 'text-muted-foreground'} />
<span className="text-xs font-medium text-muted-foreground"></span>
{alertRed > 0 && <span className="rounded bg-red-600 px-1.5 py-0.5 text-xs font-bold text-white" title={`红色 ${alertRed}`}>{alertRed}</span>}
{alertOrange > 0 && <span className="rounded bg-orange-500 px-1.5 py-0.5 text-xs font-bold text-white" title={`橙色 ${alertOrange}`}>{alertOrange}</span>}
{alertRed === 0 && alertOrange === 0 && (
<span className="flex items-center gap-1 text-xs text-green-600">
<CheckCircle size={14} />
</span>
)}
</button>
{loopScore !== null && (
<>
<div className="h-4 w-px bg-border" />
{/* 闭环健康度 */}
<button
onClick={() => navigate('/')}
title="数据来源: /tasks/loop-health · 任务生成率·店长执行率·区域周检率·月度验收率·经验推广率 五项均值"
className="flex items-center gap-1.5 rounded px-2 py-0.5 transition-colors hover:bg-muted"
>
<span className="text-xs font-medium text-muted-foreground"></span>
<span className={cn(
'text-sm font-bold',
loopScore >= 80 ? 'text-green-600' : loopScore >= 60 ? 'text-yellow-600' : 'text-red-600'
)}>
{loopScore.toFixed(1)}%
</span>
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full',
loopScore >= 80 ? 'bg-green-500' : loopScore >= 60 ? 'bg-yellow-500' : 'bg-red-500'
)}
style={{ width: `${Math.min(loopScore, 100)}%` }}
/>
</div>
</button>
</>
)}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span>{m}</span>
{alertRed > 0 ? (
<span className="flex items-center gap-1 text-red-600">
<XCircle size={12} />
</span>
) : (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle size={12} />
</span>
)}
</div>
</div>
)
}
+89 -63
View File
@@ -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, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart, Area, AreaChart } from 'recharts'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart, Area, AreaChart, RadialBarChart, RadialBar } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
@@ -10,7 +10,7 @@ import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MonthPicker } from '@/components/MonthPicker'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { TrendingUp, TrendingDown, Minus, AlertTriangle } from 'lucide-react'
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
@@ -202,15 +202,55 @@ export function DashboardPage() {
</div>
<div className="flex items-center gap-3">
<MonthPicker month={month} onChange={setMonth} />
<div className="flex gap-2">
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} </span>
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700"> {riskSummary['红色'] || 0}</span>
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700"> {riskSummary['黄色'] || 0}</span>
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿 {riskSummary['绿色'] || 0}</span>
</div>
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} </span>
</div>
</div>
{/* 风险分布条 */}
<div className="rounded-lg border bg-card p-3 shadow-sm">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"></span>
<div className="flex gap-3 text-xs">
<span className="flex items-center gap-1"><span className="h-2.5 w-2.5 rounded-full bg-red-500" /><span className="font-bold text-red-600">{riskSummary['红色'] || 0}</span> ({Math.round((riskSummary['红色'] || 0) / totalStores * 100)}%)</span>
<span className="flex items-center gap-1"><span className="h-2.5 w-2.5 rounded-full bg-yellow-500" /><span className="font-bold text-yellow-600">{riskSummary['黄色'] || 0}</span> ({Math.round((riskSummary['黄色'] || 0) / totalStores * 100)}%)</span>
<span className="flex items-center gap-1"><span className="h-2.5 w-2.5 rounded-full bg-green-500" /><span className="font-bold text-green-600">{riskSummary['绿色'] || 0}</span> 绿 ({Math.round((riskSummary['绿色'] || 0) / totalStores * 100)}%)</span>
</div>
</div>
<div className="flex h-3 w-full overflow-hidden rounded-full">
<div className="bg-red-500 transition-all" style={{ width: `${(riskSummary['红色'] || 0) / totalStores * 100}%` }} />
<div className="bg-yellow-500 transition-all" style={{ width: `${(riskSummary['黄色'] || 0) / totalStores * 100}%` }} />
<div className="bg-green-500 transition-all" style={{ width: `${(riskSummary['绿色'] || 0) / totalStores * 100}%` }} />
</div>
</div>
{/* 态势感知预警 - 前置 */}
{alerts.length > 0 && (
<CollapsibleSection
title="态势感知预警"
subtitle="营收异动 · 成本异动 · 人力异动 · 平台依赖 · 任务逾期"
defaultOpen={alertSummary?.red > 0}
headerRight={
<div className="flex gap-2">
{alertSummary?.red > 0 && <span className="rounded-md bg-red-600 px-2 py-0.5 text-xs font-bold text-white">{alertSummary.red} </span>}
{alertSummary?.orange > 0 && <span className="rounded-md bg-orange-500 px-2 py-0.5 text-xs font-bold text-white">{alertSummary.orange} </span>}
<span className="text-xs text-muted-foreground"> {alertSummary?.total || 0} </span>
</div>
}
>
<div className="grid gap-2 md:grid-cols-2">
{alerts.slice(0, 6).map((a, i) => (
<div key={i} className={`flex items-start gap-2 rounded-md border p-2.5 ${a.level === 'red' ? 'border-red-300 bg-red-50/60 border-l-4 border-l-red-500' : a.level === 'orange' ? 'border-orange-300 bg-orange-50/60 border-l-4 border-l-orange-500' : 'border-yellow-300 bg-yellow-50/60 border-l-4 border-l-yellow-500'}`}>
<AlertTriangle size={16} className={`mt-0.5 shrink-0 ${a.level === 'red' ? 'text-red-600' : a.level === 'orange' ? 'text-orange-600' : 'text-yellow-600'}`} />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{a.title}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.detail}</p>
</div>
</div>
))}
</div>
</CollapsibleSection>
)}
{/* 经营指标 */}
<CollapsibleSection title="经营指标" subtitle="营业收入、优惠、实收等核心经营数据">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
@@ -296,62 +336,48 @@ export function DashboardPage() {
</div>
}
>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
{[
{ label: '任务生成率', value: lh.task_generation_rate, desc: '系统自动生成的整改任务占应生成任务的比例' },
{ label: '店长执行率', value: lh.store_execution_rate, desc: '店长已提交反馈的任务占分配任务的比例' },
{ label: '区域周检率', value: lh.weekly_check_rate, desc: '区域经理完成周度检查的比例' },
{ label: '月度验收率', value: lh.monthly_review_rate, desc: '月度完成验收的任务比例' },
{ label: '经验推广率', value: lh.practice_promotion_rate, desc: '达标经验被推广到其他门店的比例' },
].map((item) => {
const num = parseFloat(item.value)
const isLow = num < 60
const isMid = num >= 60 && num < 80
return (
<div key={item.label} className={`rounded-md border p-3 ${isLow ? 'border-red-200 bg-red-50/50' : isMid ? 'border-yellow-200 bg-yellow-50/50' : 'border-green-200 bg-green-50/50'}`} title={item.desc}>
<div className="flex items-center gap-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<span className="flex h-3.5 w-3.5 cursor-help items-center justify-center rounded-full bg-muted text-[9px] text-muted-foreground" title={item.desc}>?</span>
</div>
<p className={`mt-1 text-xl font-bold ${isLow ? 'text-red-600' : isMid ? 'text-yellow-600' : 'text-green-600'}`}>{num.toFixed(1)}%</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${isLow ? 'bg-red-500' : isMid ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${Math.min(num, 100)}%` }}
/>
</div>
</div>
)
})}
</div>
</CollapsibleSection>
)}
{/* 态势感知预警 */}
{alerts.length > 0 && (
<CollapsibleSection
title="态势感知预警"
subtitle="营收异动 · 成本异动 · 人力异动 · 平台依赖 · 任务逾期"
headerRight={
<div className="flex gap-2">
{alertSummary?.red > 0 && <span className="rounded-md bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700"> {alertSummary.red}</span>}
{alertSummary?.orange > 0 && <span className="rounded-md bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-700"> {alertSummary.orange}</span>}
<span className="text-xs text-muted-foreground"> {alertSummary?.total || 0} </span>
</div>
}
>
<div className="grid gap-2 md:grid-cols-2">
{alerts.slice(0, 6).map((a, i) => (
<div key={i} className={`flex items-start gap-2 rounded-md border p-2.5 ${a.level === 'red' ? 'border-red-200 bg-red-50/50' : a.level === 'orange' ? 'border-orange-200 bg-orange-50/50' : 'border-yellow-200 bg-yellow-50/50'}`}>
<span className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${a.level === 'red' ? 'text-red-700' : a.level === 'orange' ? 'text-orange-700' : 'text-yellow-700'}`}>
{a.level === 'red' ? '红' : a.level === 'orange' ? '橙' : '黄'}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{a.title}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.detail}</p>
</div>
<div className="grid gap-4 lg:grid-cols-5">
{/* 仪表盘 */}
<div className="flex flex-col items-center justify-center lg:col-span-1">
<ResponsiveContainer width="100%" height={160}>
<RadialBarChart innerRadius="65%" outerRadius="100%" data={[{ name: '综合得分', value: loopScore, fill: loopScore >= 80 ? '#22c55e' : loopScore >= 60 ? '#eab308' : '#ef4444' }]} startAngle={90} endAngle={-270}>
<RadialBar background dataKey="value" cornerRadius={8} />
</RadialBarChart>
</ResponsiveContainer>
<div className="-mt-24 text-center">
<p className={`text-2xl font-bold ${loopScore >= 80 ? 'text-green-600' : loopScore >= 60 ? 'text-yellow-600' : 'text-red-600'}`}>{loopScore.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground"></p>
</div>
))}
</div>
{/* 5个指标条 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-5 lg:col-span-4">
{[
{ label: '任务生成率', value: lh.task_generation_rate, desc: '系统自动生成的整改任务占应生成任务的比例' },
{ label: '店长执行率', value: lh.store_execution_rate, desc: '店长已提交反馈的任务占分配任务的比例' },
{ label: '区域周检率', value: lh.weekly_check_rate, desc: '区域经理完成周度检查的比例' },
{ label: '月度验收率', value: lh.monthly_review_rate, desc: '月度完成验收的任务比例' },
{ label: '经验推广率', value: lh.practice_promotion_rate, desc: '达标经验被推广到其他门店的比例' },
].map((item) => {
const num = parseFloat(item.value)
const isLow = num < 60
const isMid = num >= 60 && num < 80
return (
<div key={item.label} className={`rounded-md border p-3 ${isLow ? 'border-red-200 bg-red-50/50' : isMid ? 'border-yellow-200 bg-yellow-50/50' : 'border-green-200 bg-green-50/50'}`} title={item.desc}>
<div className="flex items-center gap-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<span className="flex h-3.5 w-3.5 cursor-help items-center justify-center rounded-full bg-muted text-[9px] text-muted-foreground" title={item.desc}>?</span>
</div>
<p className={`mt-1 text-xl font-bold ${isLow ? 'text-red-600' : isMid ? 'text-yellow-600' : 'text-green-600'}`}>{num.toFixed(1)}%</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${isLow ? 'bg-red-500' : isMid ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${Math.min(num, 100)}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
</CollapsibleSection>
)}