feat: 新增银行授信数据报告页面 - 可配置评分规则+5大分析模块
This commit is contained in:
@@ -25,6 +25,7 @@ import { SmartSchedulingPage } from '@/pages/SmartSchedulingPage'
|
||||
import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage'
|
||||
import { BossPage } from '@/pages/BossPage'
|
||||
import { RevenuePage } from '@/pages/RevenuePage'
|
||||
import { BankPage } from '@/pages/BankPage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -71,6 +72,7 @@ export default function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/boss" element={<BossPage />} />
|
||||
<Route path="/revenue" element={<RevenuePage />} />
|
||||
<Route path="/bank" element={<BankPage />} />
|
||||
<Route path="/regional" element={<RegionalPage />} />
|
||||
<Route path="/store" element={<StorePage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 } from 'lucide-react'
|
||||
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 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -28,6 +28,7 @@ const menuGroups: MenuGroup[] = [
|
||||
{ path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] },
|
||||
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] },
|
||||
{ path: '/bank', label: '银行授信', icon: Landmark, roles: ['hq'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { Landmark, Settings2, RotateCcw } from 'lucide-react'
|
||||
|
||||
// ============ 评分规则类型 ============
|
||||
interface ScoringRule {
|
||||
weight: number
|
||||
excellent: number
|
||||
pass: number
|
||||
higherIsBetter: boolean
|
||||
}
|
||||
|
||||
interface ScoringConfig {
|
||||
netMargin: ScoringRule
|
||||
stability: ScoringRule
|
||||
storeHealth: ScoringRule
|
||||
scale: ScoringRule
|
||||
discountRate: ScoringRule
|
||||
memberShare: ScoringRule
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ScoringConfig = {
|
||||
netMargin: { weight: 25, excellent: 10, pass: 5, higherIsBetter: true },
|
||||
stability: { weight: 20, excellent: 0.15, pass: 0.3, higherIsBetter: false },
|
||||
storeHealth: { weight: 20, excellent: 10, pass: 25, higherIsBetter: false },
|
||||
scale: { weight: 15, excellent: 500000, pass: 200000, higherIsBetter: true },
|
||||
discountRate: { weight: 10, excellent: 18, pass: 25, higherIsBetter: false },
|
||||
memberShare: { weight: 10, excellent: 15, pass: 5, higherIsBetter: true },
|
||||
}
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
cash: '现金', alipay: '支付宝', wechat: '微信', meituan: '美团堂食', unionpay: '银联',
|
||||
douyin: '抖音', credit: '挂账', jd_delivery: '京东外卖', meituan_delivery: '美团外卖', taobao_delivery: '淘宝外卖',
|
||||
}
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
wechat: '#07c160', alipay: '#1677ff', meituan_delivery: '#ffd700', meituan: '#ff6b35',
|
||||
taobao_delivery: '#ff4400', douyin: '#000000', cash: '#52c41a', jd_delivery: '#e1251b',
|
||||
unionpay: '#f5222d', credit: '#722ed1',
|
||||
}
|
||||
|
||||
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
|
||||
|
||||
function loadConfig(): ScoringConfig {
|
||||
try {
|
||||
const saved = localStorage.getItem('bank-scoring-config')
|
||||
if (saved) return { ...DEFAULT_CONFIG, ...JSON.parse(saved) }
|
||||
} catch {}
|
||||
return DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
function saveConfig(config: ScoringConfig) {
|
||||
localStorage.setItem('bank-scoring-config', JSON.stringify(config))
|
||||
}
|
||||
|
||||
// 计算单项得分 (0-100)
|
||||
function scoreItem(value: number, rule: ScoringRule): number {
|
||||
if (rule.higherIsBetter) {
|
||||
if (value >= rule.excellent) return 100
|
||||
if (value <= rule.pass) return 0
|
||||
return Math.round((value - rule.pass) / (rule.excellent - rule.pass) * 100)
|
||||
} else {
|
||||
if (value <= rule.excellent) return 100
|
||||
if (value >= rule.pass) return 0
|
||||
return Math.round((rule.pass - value) / (rule.pass - rule.excellent) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算信用等级
|
||||
function getGrade(totalScore: number): { grade: string; color: string; label: string } {
|
||||
if (totalScore >= 85) return { grade: 'A', color: '#22c55e', label: '优秀 - 建议授信' }
|
||||
if (totalScore >= 70) return { grade: 'B', color: '#3b82f6', label: '良好 - 可授信' }
|
||||
if (totalScore >= 50) return { grade: 'C', color: '#eab308', label: '一般 - 谨慎授信' }
|
||||
return { grade: 'D', color: '#ef4444', label: '风险 - 不建议授信' }
|
||||
}
|
||||
|
||||
export function BankPage() {
|
||||
const [config, setConfig] = useState<ScoringConfig>(DEFAULT_CONFIG)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
|
||||
useEffect(() => { setConfig(loadConfig()) }, [])
|
||||
|
||||
const { data: reportData, isLoading } = useQuery({
|
||||
queryKey: ['bank/report'],
|
||||
queryFn: () => api.get('/bank/report'),
|
||||
})
|
||||
|
||||
const d = (reportData as any)?.data
|
||||
const ov = d?.overview
|
||||
const daily = d?.daily || []
|
||||
const wf = d?.waterfall
|
||||
const risk = d?.risk || []
|
||||
const channel = d?.channel || {}
|
||||
const stores = d?.stores || []
|
||||
const stability = d?.stability
|
||||
|
||||
// 计算各项指标值
|
||||
const metrics = useMemo(() => {
|
||||
if (!ov || !wf || !stability) return null
|
||||
const netMargin = wf.received > 0 ? (wf.net_profit / wf.received * 100) : 0
|
||||
const cv = stability.cv
|
||||
const redStores = risk.filter((r: any) => r.risk_level === '红色').reduce((s: number, r: any) => s + r.store_count, 0)
|
||||
const totalStores = risk.reduce((s: number, r: any) => s + r.store_count, 0)
|
||||
const redPct = totalStores > 0 ? (redStores / totalStores * 100) : 0
|
||||
const scale = ov.received
|
||||
const discountRate = ov.discount_rate_pct
|
||||
const memberShare = ov.member_share_pct
|
||||
return { netMargin, cv, redPct, scale, discountRate, memberShare }
|
||||
}, [ov, wf, stability, risk])
|
||||
|
||||
// 计算评分
|
||||
const scoring = useMemo(() => {
|
||||
if (!metrics) return null
|
||||
const items = [
|
||||
{ key: 'netMargin', label: '净利率', value: metrics.netMargin, unit: '%', rule: config.netMargin, score: scoreItem(metrics.netMargin, config.netMargin) },
|
||||
{ key: 'stability', label: '营收稳定性', value: metrics.cv, unit: '', rule: config.stability, score: scoreItem(metrics.cv, config.stability) },
|
||||
{ key: 'storeHealth', label: '门店健康度', value: metrics.redPct, unit: '%', rule: config.storeHealth, score: scoreItem(metrics.redPct, config.storeHealth) },
|
||||
{ key: 'scale', label: '规模体量', value: metrics.scale, unit: '元', rule: config.scale, score: scoreItem(metrics.scale, config.scale) },
|
||||
{ key: 'discountRate', label: '优惠率', value: metrics.discountRate, unit: '%', rule: config.discountRate, score: scoreItem(metrics.discountRate, config.discountRate) },
|
||||
{ key: 'memberShare', label: '会员占比', value: metrics.memberShare, unit: '%', rule: config.memberShare, score: scoreItem(metrics.memberShare, config.memberShare) },
|
||||
]
|
||||
const totalWeight = items.reduce((s, i) => s + i.rule.weight, 0)
|
||||
const totalScore = totalWeight > 0 ? Math.round(items.reduce((s, i) => s + i.score * i.rule.weight, 0) / totalWeight) : 0
|
||||
return { items, totalScore, totalWeight, grade: getGrade(totalScore) }
|
||||
}, [metrics, config])
|
||||
|
||||
// 利润瀑布数据
|
||||
const waterfallChart = useMemo(() => {
|
||||
if (!wf) return []
|
||||
return [
|
||||
{ name: '实收', value: wf.received, base: 0, fill: '#3b82f6' },
|
||||
{ name: '食材', value: -wf.food_cost, base: wf.received, fill: '#f97316' },
|
||||
{ name: '人工', value: -wf.wage, base: wf.received - wf.food_cost, fill: '#3b82f6' },
|
||||
{ name: '房租', value: -wf.rent, base: wf.received - wf.food_cost - wf.wage, fill: '#8b5cf6' },
|
||||
{ name: '水电', value: -wf.utility, base: wf.received - wf.food_cost - wf.wage - wf.rent, fill: '#06b6d4' },
|
||||
{ name: '其他', value: -(wf.dorm + wf.commission + wf.other_expense), base: wf.received - wf.food_cost - wf.wage - wf.rent - wf.utility, fill: '#64748b' },
|
||||
{ name: '净利润', value: wf.net_profit, base: 0, fill: wf.net_profit >= 0 ? '#22c55e' : '#ef4444' },
|
||||
]
|
||||
}, [wf])
|
||||
|
||||
// 渠道饼图
|
||||
const channelPie = useMemo(() => {
|
||||
return Object.entries(channel).map(([k, v]) => ({ name: k, value: v as number })).sort((a, b) => b.value - a.value)
|
||||
}, [channel])
|
||||
|
||||
// 风险饼图
|
||||
const riskPie = useMemo(() => {
|
||||
return risk.map((r: any) => ({ name: r.risk_level, value: r.store_count, fill: RISK_COLORS[r.risk_level] || '#999' }))
|
||||
}, [risk])
|
||||
|
||||
// 日度趋势
|
||||
const dailyChart = useMemo(() => {
|
||||
return daily.map((r: any) => ({ date: r.business_date?.substring(5, 10), received: r.received, bills: r.bill_count }))
|
||||
}, [daily])
|
||||
|
||||
const top10 = stores.slice(0, 10)
|
||||
const totalChannel = channelPie.reduce((s, c) => s + c.value, 0)
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载银行授信数据..." />
|
||||
|
||||
const updateRule = (key: keyof ScoringConfig, field: keyof ScoringRule, value: number) => {
|
||||
const newConfig = { ...config, [key]: { ...config[key], [field]: value } }
|
||||
setConfig(newConfig)
|
||||
saveConfig(newConfig)
|
||||
}
|
||||
|
||||
const resetConfig = () => {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
saveConfig(DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold"><Landmark size={20} /> 银行授信数据报告</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">企业经营数据 · 信用评估 · 2026年4月</p>
|
||||
</div>
|
||||
<button onClick={() => setShowConfig(!showConfig)} className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs hover:bg-accent">
|
||||
<Settings2 size={14} /> 评分规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 评分规则配置面板 */}
|
||||
{showConfig && (
|
||||
<CollapsibleSection title="评分规则配置" subtitle="调整各指标权重和阈值,实时重新计算信用等级" defaultOpen={true}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
{(Object.keys(config) as (keyof ScoringConfig)[]).map(key => {
|
||||
const rule = config[key]
|
||||
const labels: Record<string, string> = { netMargin: '净利率(%)', stability: '营收稳定性(CV)', storeHealth: '门店健康度(红色占比%)', scale: '规模体量(元)', discountRate: '优惠率(%)', memberShare: '会员占比(%)' }
|
||||
return (
|
||||
<div key={key} className="rounded-lg border p-3 space-y-2">
|
||||
<p className="text-xs font-bold">{labels[key]}</p>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">权重(%)</label>
|
||||
<input type="number" value={rule.weight} onChange={e => updateRule(key, 'weight', parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded border px-2 py-1 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">{rule.higherIsBetter ? '优秀阈值' : '优秀阈值(≤)'}</label>
|
||||
<input type="number" step="0.01" value={rule.excellent} onChange={e => updateRule(key, 'excellent', parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded border px-2 py-1 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">{rule.higherIsBetter ? '及格阈值(≥)' : '及格阈值(≥)'}</label>
|
||||
<input type="number" step="0.01" value={rule.pass} onChange={e => updateRule(key, 'pass', parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded border px-2 py-1 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs">权重总和: <strong className={scoring?.totalWeight === 100 ? 'text-green-600' : 'text-red-600'}>{scoring?.totalWeight || 0}%</strong></span>
|
||||
<button onClick={resetConfig} className="flex items-center gap-1 rounded-md border px-2 py-1 text-xs hover:bg-accent">
|
||||
<RotateCcw size={12} /> 重置默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* ① 企业经营概况 */}
|
||||
<CollapsibleSection title="企业经营概况" subtitle="月度核心指标与日度营收趋势">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="月度实收" value={ov?.received} format="currency" description={`${formatNumber(ov?.bill_count)} 笔账单`} />
|
||||
<MetricCard title="客单价" value={ov?.avg_bill_value} format="currency" description="实收 ÷ 账单数" />
|
||||
<MetricCard title="理论毛利率" value={ov?.theoretical_margin_pct} format="percent" description="菜品定价毛利空间" />
|
||||
<MetricCard title="门店数量" value={stores.length} unit="家" description={`活跃经营门店总数`} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={dailyChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '日实收']} />
|
||||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="日实收" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ② 盈利能力分析 */}
|
||||
<CollapsibleSection title="盈利能力分析" subtitle="利润瀑布 · 净利率 · 成本费用结构">
|
||||
{wf && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="净利润" value={wf.net_profit} format="currency" status={wf.net_profit > 0 ? 'good' : 'bad'} description={`净利率 ${wf.received > 0 ? formatPercent(wf.net_profit / wf.received * 100) : '-'}`} />
|
||||
<MetricCard title="食材成本率" value={wf.received > 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" />
|
||||
<MetricCard title="费用率" value={wf.received > 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" />
|
||||
<MetricCard title="实收分配" value={wf.received > 0 ? wf.net_profit / wf.received * 100 : 0} format="percent" description="净利润占实收比例" />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="middle" height={60} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]} shape={(props: any) => {
|
||||
const { x, y, width, height, payload, fill } = props
|
||||
if (!payload.value || payload.value === 0) return <rect />
|
||||
const scale = height / payload.value
|
||||
const newY = y - payload.base * scale
|
||||
return <rect x={x} y={newY} width={width} height={height} fill={fill} rx={4} ry={4} />
|
||||
}}>
|
||||
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ③ 门店资产质量 */}
|
||||
<CollapsibleSection title="门店资产质量" subtitle="风险等级分布 · 营收TOP10">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}家`}>
|
||||
{riskPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [`${v}家`, name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={top10} layout="vertical" margin={{ top: 5, right: 10, left: 80, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="store_name" tick={{ fontSize: 9 }} width={80} />
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
|
||||
<Bar dataKey="received" fill="#3b82f6" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ④ 经营稳定性指标 */}
|
||||
<CollapsibleSection title="经营稳定性指标" subtitle="营收波动率 · 优惠率 · 会员占比 · 渠道结构">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="日度营收波动率" value={stability ? stability.cv * 100 : 0} format="percent" status={stability && stability.cv < 0.2 ? 'good' : stability && stability.cv < 0.3 ? 'warn' : 'bad'} description={`变异系数CV · 日均${formatCurrency(stability?.mean_daily_revenue)}`} />
|
||||
<MetricCard title="优惠率" value={ov?.discount_rate_pct} format="percent" status={Number(ov?.discount_rate_pct) > 25 ? 'bad' : Number(ov?.discount_rate_pct) > 18 ? 'warn' : 'good'} description="优惠额 ÷ (实收+优惠额)" />
|
||||
<MetricCard title="会员消费占比" value={ov?.member_share_pct} format="percent" status={Number(ov?.member_share_pct) > 15 ? 'good' : 'warn'} description="会员账单 ÷ 总账单" />
|
||||
<MetricCard title="活跃渠道数" value={channelPie.length} unit="个" description="有交易记录的支付渠道" />
|
||||
</div>
|
||||
{channelPie.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={(e: any) => `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}>
|
||||
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), CHANNEL_LABELS[name] || name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => CHANNEL_LABELS[v] || v} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ⑤ 银行授信评估摘要 */}
|
||||
<CollapsibleSection title="银行授信评估摘要" subtitle="基于可配置评分规则的信用等级评估">
|
||||
{scoring && (
|
||||
<>
|
||||
{/* 信用等级总览 */}
|
||||
<div className="flex items-center gap-6 rounded-lg border p-4" style={{ borderColor: scoring.grade.color + '40' }}>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full text-4xl font-bold" style={{ backgroundColor: scoring.grade.color + '20', color: scoring.grade.color }}>
|
||||
{scoring.grade.grade}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-lg font-bold" style={{ color: scoring.grade.color }}>{scoring.grade.label}</p>
|
||||
<p className="mt-1 text-2xl font-bold">{scoring.totalScore}<span className="text-sm text-muted-foreground"> / 100 分</span></p>
|
||||
<p className="text-xs text-muted-foreground">综合信用评分 · 权重总和 {scoring.totalWeight}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 各指标得分明细 */}
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4">指标</th>
|
||||
<th className="pb-2 pr-4">实际值</th>
|
||||
<th className="pb-2 pr-4">优秀阈值</th>
|
||||
<th className="pb-2 pr-4">及格阈值</th>
|
||||
<th className="pb-2 pr-4">权重</th>
|
||||
<th className="pb-2 pr-4">得分</th>
|
||||
<th className="pb-2">加权得分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoring.items.map(item => (
|
||||
<tr key={item.key} className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">{item.label}</td>
|
||||
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.value) : `${item.value.toFixed(2)}${item.unit}`}</td>
|
||||
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.excellent) : `${item.rule.excellent}${item.unit}`}</td>
|
||||
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.pass) : `${item.rule.pass}${item.unit}`}</td>
|
||||
<td className="py-2 pr-4">{item.rule.weight}%</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-bold" style={{ color: item.score >= 80 ? '#22c55e' : item.score >= 50 ? '#eab308' : '#ef4444' }}>{item.score}</span>
|
||||
</td>
|
||||
<td className="py-2">{(item.score * item.rule.weight / 100).toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 风险提示 */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-bold">风险提示</p>
|
||||
{scoring.items.filter(i => i.score < 60).map(i => (
|
||||
<div key={i.key} className="flex items-center gap-2 rounded border border-yellow-200 bg-yellow-50/40 p-2 text-xs">
|
||||
<span className="text-yellow-600">⚠</span>
|
||||
<span><strong>{i.label}</strong>:得分 {i.score},实际值 {i.unit === '元' ? formatCurrency(i.value) : `${i.value.toFixed(2)}${i.unit}`},{i.rule.higherIsBetter ? `建议提升至 ${i.unit === '元' ? formatCurrency(i.rule.excellent) : i.rule.excellent + i.unit} 以上` : `建议控制在 ${i.rule.excellent}${i.unit} 以内`}</span>
|
||||
</div>
|
||||
))}
|
||||
{scoring.items.filter(i => i.score < 60).length === 0 && (
|
||||
<div className="flex items-center gap-2 rounded border border-green-200 bg-green-50/40 p-2 text-xs">
|
||||
<span className="text-green-600">✓</span>
|
||||
<span>各项指标均达标,经营状况良好</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user