Files
lzwcai-mcp-server-package/lzwcai_mcpskills_mfg_data_agent/html/SupplyChainRiskWarning.html
yuanzhipeng 3ea772c3be feat(mfg-data-agent): 添加HTML可视化仪表盘和优化项目配置
- 新增6个HTML可视化仪表盘组件用于数据展示
* 人效产值损耗三维模型仪表盘
* 指标趋势分析与拐点预警仪表盘
* 一页式决策简报仪表盘
* 订单延迟预警分析仪表盘
* 供应链风险预警仪表盘
* 工单执行进度与异常节点仪表盘
- 添加VSCode工作区配置文件
- 更新businessQueries.json业务查询配置
- 优化api_client.py API客户端实现
- 更新pyproject.toml项目依赖版本
- 重组SQL查询文件结构
- 删除v2版本冗余文档配置
- 添加v2版本技能清单文档
- 更新日志文件记录
2026-01-14 11:56:43 +08:00

681 lines
41 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>供应链风险预警</title>
<script src="/LzwcaiEmbedFrameFile/LzwcaiEmbedFrameV5.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
@keyframes slideIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-2px); } 75% { transform: translateX(2px); } }
.animate-slide-in { animation: slideIn 0.5s ease-out forwards; }
.animate-fade-in { animation: fadeIn 0.4s ease-out forwards; }
.animate-scale-in { animation: scaleIn 0.4s ease-out forwards; }
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
.animate-blink { animation: blink 1.5s ease-in-out infinite; }
.animate-shake { animation: shake 0.5s ease-in-out infinite; }
.glass { backdrop-filter: blur(16px); background: rgba(255,255,255,0.85); }
.gradient-text { background: linear-gradient(135deg, #ef4444 0%, #f97316 50%, #eab308 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.page-bg { background: linear-gradient(135deg, #fef2f2 0%, #fff7ed 30%, #fefce8 70%, #f8fafc 100%); min-height: 100vh; }
.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; }
.scrollbar-thin::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
.scrollbar-thin::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 50; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.2s ease-out; }
.modal-content { background: white; border-radius: 1.5rem; max-width: 90vw; max-height: 85vh; overflow: hidden; animation: scaleIn 0.3s ease-out; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); }
.clickable-row { cursor: pointer; transition: all 0.2s; }
.clickable-row:hover { background: #f8fafc !important; transform: translateX(2px); }
.clickable-card { cursor: pointer; transition: all 0.2s; }
.clickable-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px -5px rgba(0,0,0,0.1); }
</style>
<script>
window.addEventListener('DOMContentLoaded', async function() {
try {
const loader = new LibraryLoader({
enableLog: true, async: false,
libraries: [
{ name: 'react', src: '/LzwcaiEmbedFrameFile/lib/react.production.min.js', check: () => typeof window.React !== 'undefined' },
{ name: 'reactDOM', src: '/LzwcaiEmbedFrameFile/lib/react-dom.production.min.js', check: () => typeof window.ReactDOM !== 'undefined' },
{ name: 'htm', src: '/LzwcaiEmbedFrameFile/lib/htm.js', check: () => typeof window.htm !== 'undefined' },
{ name: 'tailwindcss', src: '/LzwcaiEmbedFrameFile/lib/tailwindcss-3.4.17.js', check: () => typeof window.tailwind !== 'undefined' },
{ name: 'g2', src: '/LzwcaiEmbedFrameFile/lib/g2@5.2.4.min.js', check: () => typeof window.G2 !== 'undefined' }
]
});
await loader.loadAll();
initReactApp();
} catch (error) { console.error('[SupplyChainRiskWarning] 库加载失败:', error); }
});
function initReactApp() {
const { useState, useEffect, useMemo, useRef } = React;
const html = htm.bind(React.createElement);
const config = { enableLog: true, title: '供应链风险预警' };
const lzwcaiComInitDate = '{{lzwcaiComInitDate}}';
const defaultData = { success: false, data: [[], [], [], [], [], []] };
// 风险等级配置
const riskLevelConfig = {
'HIGH': { label: '高风险', bg: 'from-red-500 to-rose-600', light: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', icon: '🔴', dot: 'bg-red-500' },
'MEDIUM': { label: '中风险', bg: 'from-amber-400 to-orange-500', light: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200', icon: '🟡', dot: 'bg-amber-500' },
'LOW': { label: '低风险', bg: 'from-emerald-400 to-teal-500', light: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200', icon: '🟢', dot: 'bg-emerald-500' }
};
// 风险模式配置
const riskPatternConfig = {
'DELIVERY_AND_QUALITY': { label: '交期+质量双风险', icon: '⚠️', color: 'text-red-600', bg: 'bg-red-50' },
'DELIVERY_ISSUE': { label: '交期异常', icon: '⏰', color: 'text-orange-600', bg: 'bg-orange-50' },
'QUALITY_ISSUE': { label: '质量问题', icon: '🔍', color: 'text-purple-600', bg: 'bg-purple-50' },
'LOGISTICS_STALL': { label: '物流停滞', icon: '🚚', color: 'text-blue-600', bg: 'bg-blue-50' },
'NORMAL': { label: '正常', icon: '✅', color: 'text-emerald-600', bg: 'bg-emerald-50' }
};
// 行动要求配置
const actionConfig = {
'IMMEDIATE_FOLLOWUP': { label: '立即跟进', icon: '🚨', color: 'text-red-600', bg: 'bg-red-100' },
'MONITOR': { label: '持续监控', icon: '👁️', color: 'text-amber-600', bg: 'bg-amber-100' },
'NORMAL': { label: '正常', icon: '✓', color: 'text-emerald-600', bg: 'bg-emerald-100' }
};
// 风险评估配置
const assessmentConfig = {
'HIGH_RISK': { label: '高风险', color: 'text-red-600', bg: 'bg-red-50' },
'MEDIUM_RISK': { label: '中风险', color: 'text-amber-600', bg: 'bg-amber-50' },
'EXCELLENT': { label: '优秀', color: 'text-emerald-600', bg: 'bg-emerald-50' }
};
// 空状态组件
const EmptyState = ({ icon, title, desc }) => html`
<div class="flex flex-col items-center justify-center py-10 text-center">
<div class="w-14 h-14 bg-gradient-to-br from-slate-100 to-slate-200 rounded-2xl flex items-center justify-center text-2xl mb-3">${icon}</div>
<p class="text-sm font-medium text-slate-500">${title}</p>
<p class="text-xs text-slate-400 mt-1">${desc}</p>
</div>
`;
// 弹窗组件
const Modal = ({ isOpen, onClose, title, icon, children }) => {
if (!isOpen) return null;
return html`
<div class="modal-overlay" onClick=${onClose}>
<div class="modal-content w-full max-w-2xl" onClick=${e => e.stopPropagation()}>
<div class="px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white flex items-center justify-between">
<h3 class="text-lg font-bold text-slate-800 flex items-center gap-2">
<span class="text-xl">${icon}</span>
${title}
</h3>
<button onClick=${onClose} class="w-8 h-8 rounded-full bg-slate-100 hover:bg-slate-200 flex items-center justify-center text-slate-500 hover:text-slate-700 transition-colors"></button>
</div>
<div class="p-6 max-h-[60vh] overflow-y-auto scrollbar-thin">
${children}
</div>
</div>
</div>
`;
};
function ChartApp() {
const [rawData, setRawData] = useState(defaultData);
const [pageHelper, setPageHelper] = useState(null);
const [error, setError] = useState(null);
const [supplierPage, setSupplierPage] = useState(1);
const [orderPage, setOrderPage] = useState(1);
const [filterRisk, setFilterRisk] = useState('all');
const [filterPattern, setFilterPattern] = useState('all');
// 弹窗状态
const [modalData, setModalData] = useState(null);
const [modalType, setModalType] = useState(null);
const pageSize = 10;
const chartRef1 = useRef(null);
const chartRef3 = useRef(null);
const chartInstance1 = useRef(null);
const chartInstance3 = useRef(null);
const validateData = (data) => {
if (!data || typeof data !== 'object') return { valid: false, error: '数据格式错误' };
if (!Array.isArray(data.data)) return { valid: false, error: 'data 字段应为数组' };
return { valid: true };
};
// 打开弹窗
const openModal = (type, data) => {
setModalType(type);
setModalData(data);
};
// 关闭弹窗
const closeModal = () => {
setModalType(null);
setModalData(null);
};
// 处理数据
const processedData = useMemo(() => {
if (!rawData.data || rawData.data.length < 6) return null;
const [suppliers = [], highRiskOrders = [], categoryRisk = [], monthlyTrend = [], metrics = [], riskRanking = []] = rawData.data;
const metricsMap = {};
metrics.forEach(m => { metricsMap[m.metric_name] = m; });
const riskDistribution = { HIGH: 0, MEDIUM: 0, LOW: 0 };
suppliers.forEach(s => {
if (riskDistribution[s.risk_level] !== undefined) riskDistribution[s.risk_level]++;
});
const patternStats = {};
suppliers.forEach(s => {
const pattern = s.risk_pattern || 'NORMAL';
patternStats[pattern] = (patternStats[pattern] || 0) + 1;
});
const categoryStats = categoryRisk.map(c => ({
...c,
supplier_count: parseInt(c.supplier_count || 0),
order_count: parseInt(c.order_count || 0),
return_count: parseInt(c.return_count || 0)
}));
return { suppliers, highRiskOrders, categoryRisk: categoryStats, monthlyTrend, metrics: metricsMap, riskRanking, riskDistribution, patternStats };
}, [rawData]);
const filteredSuppliers = useMemo(() => {
if (!processedData) return [];
let list = processedData.suppliers;
if (filterRisk !== 'all') list = list.filter(s => s.risk_level === filterRisk);
if (filterPattern !== 'all') list = list.filter(s => s.risk_pattern === filterPattern);
return list;
}, [processedData, filterRisk, filterPattern]);
const paginatedSuppliers = useMemo(() => {
const start = (supplierPage - 1) * pageSize;
return filteredSuppliers.slice(start, start + pageSize);
}, [filteredSuppliers, supplierPage]);
const paginatedOrders = useMemo(() => {
if (!processedData) return [];
const start = (orderPage - 1) * pageSize;
return processedData.highRiskOrders.slice(start, start + pageSize);
}, [processedData, orderPage]);
const supplierTotalPages = Math.ceil(filteredSuppliers.length / pageSize);
const orderTotalPages = Math.ceil((processedData?.highRiskOrders?.length || 0) / pageSize);
// 渲染图表
useEffect(() => {
if (!processedData || !window.G2) return;
[chartInstance1, chartInstance3].forEach(ref => {
if (ref.current) { ref.current.destroy(); ref.current = null; }
});
if (chartRef1.current && Object.values(processedData.riskDistribution).some(v => v > 0)) {
const riskData = Object.entries(processedData.riskDistribution)
.filter(([_, v]) => v > 0)
.map(([level, count]) => ({ name: riskLevelConfig[level]?.label || level, value: count }));
chartInstance1.current = new G2.Chart({ container: chartRef1.current, autoFit: true, height: 180 });
chartInstance1.current.coordinate({ type: 'theta', innerRadius: 0.6 });
chartInstance1.current.interval()
.data(riskData)
.transform({ type: 'stackY' })
.encode('y', 'value').encode('color', 'name')
.scale('color', { range: ['#ef4444', '#f59e0b', '#10b981'] })
.style('stroke', '#fff').style('lineWidth', 2)
.label({ text: d => d.value > 0 ? `${d.name}\n${d.value}家` : '', position: 'outside', fontSize: 10 })
.legend(false)
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 家供应商' }] });
chartInstance1.current.render();
}
if (chartRef3.current && processedData.suppliers.length > 0) {
const topRiskSuppliers = [...processedData.suppliers]
.filter(s => s.total_risk_score > 0)
.sort((a, b) => b.total_risk_score - a.total_risk_score)
.slice(0, 6)
.map(s => ({ name: s.supplier_name.length > 4 ? s.supplier_name.slice(0, 4) + '..' : s.supplier_name, 风险分: s.total_risk_score, level: s.risk_level }));
if (topRiskSuppliers.length > 0) {
chartInstance3.current = new G2.Chart({ container: chartRef3.current, autoFit: true, height: 180 });
chartInstance3.current.interval()
.data(topRiskSuppliers)
.encode('x', 'name').encode('y', '风险分').encode('color', 'level')
.scale('color', { domain: ['HIGH', 'MEDIUM', 'LOW'], range: ['#ef4444', '#f59e0b', '#10b981'] })
.scale('y', { domain: [0, 100] })
.style('radius', 6)
.axis('x', { labelFontSize: 10 })
.axis('y', { title: false })
.legend(false)
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 分' }] });
chartInstance3.current.render();
}
}
return () => {
[chartInstance1, chartInstance3].forEach(ref => {
if (ref.current) { ref.current.destroy(); ref.current = null; }
});
};
}, [processedData]);
useEffect(() => {
const helper = new ChildPageHelper({
autoRenderStatus: true, enableLog: config.enableLog,
onReady: async () => {
await helper.autoInitialize(() => {
const finalInitData = helper.parseTemplateData(lzwcaiComInitDate, defaultData);
if (finalInitData) {
const v = validateData(finalInitData);
v.valid ? (setRawData(finalInitData), setError(null)) : setError(v.error);
}
});
}
});
setPageHelper(helper);
helper.expose({
setDataLzwcaiEmbedFrameFn: (data) => {
const v = validateData(data);
if (v.valid) { setRawData(data); setError(null); return { status: 'success' }; }
setError(v.error); return { status: 'error', message: v.error };
},
getDataLzwcaiEmbedFrameFn: () => ({ status: 'success', data: rawData }),
captureScreenshotLzwcaiEmbedFrameFn: helper.createScreenshotMethod(),
getRenderStatusLzwcaiEmbedFrameFn: () => helper.getRenderStatus()
});
}, []);
const formatPercent = v => (v || 0).toFixed(1) + '%';
const getRisk = level => riskLevelConfig[level] || riskLevelConfig['LOW'];
const getPattern = pattern => riskPatternConfig[pattern] || riskPatternConfig['NORMAL'];
const getAction = action => actionConfig[action] || actionConfig['NORMAL'];
const getAssessment = assessment => assessmentConfig[assessment] || assessmentConfig['EXCELLENT'];
// 订单详情弹窗内容
const renderOrderDetail = (order) => {
const rc = getRisk(order.risk_level);
const ac = getAction(order.action_required);
const pc = getPattern(order.risk_pattern);
return html`
<div class="space-y-4">
<div class="flex items-center gap-3 p-4 rounded-xl ${order.risk_level === 'HIGH' ? 'bg-red-50' : 'bg-amber-50'}">
<span class="text-3xl">${rc.icon}</span>
<div>
<p class="text-xl font-bold text-slate-800">${order.purchase_order_number}</p>
<p class="text-sm text-slate-600">${order.supplier_name}</p>
</div>
<span class="ml-auto px-3 py-1.5 ${ac.bg} rounded-lg text-sm font-medium ${ac.color}">${ac.icon} ${ac.label}</span>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="p-3 bg-slate-50 rounded-xl">
<p class="text-xs text-slate-500 mb-1">供应商类别</p>
<p class="font-semibold text-slate-800">${order.supplier_category || '-'}</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl">
<p class="text-xs text-slate-500 mb-1">风险等级</p>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-sm font-medium ${rc.light} ${rc.text}">${rc.icon} ${rc.label}</span>
</div>
<div class="p-3 bg-slate-50 rounded-xl">
<p class="text-xs text-slate-500 mb-1">下单日期</p>
<p class="font-semibold text-slate-800">${order.order_date || '-'}</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl">
<p class="text-xs text-slate-500 mb-1">已等待天数</p>
<p class="font-semibold ${order.days_since_order > 30 ? 'text-red-600' : 'text-slate-800'}">${order.days_since_order} 天</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl">
<p class="text-xs text-slate-500 mb-1">质量合格率</p>
<p class="font-semibold ${order.quality_rate < 80 ? 'text-red-600' : order.quality_rate < 95 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(order.quality_rate)}</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl">
<p class="text-xs text-slate-500 mb-1">风险模式</p>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-sm font-medium ${pc.bg} ${pc.color}">${pc.icon} ${pc.label}</span>
</div>
</div>
${order.risk_factors && html`
<div class="p-4 bg-orange-50 rounded-xl border border-orange-100">
<p class="text-sm font-semibold text-orange-700 mb-2">⚠️ 风险因素</p>
<p class="text-sm text-orange-600">${order.risk_factors}</p>
</div>
`}
</div>
`;
};
// 供应商详情弹窗内容
const renderSupplierDetail = (s) => {
const rc = getRisk(s.risk_level);
const pc = getPattern(s.risk_pattern);
return html`
<div class="space-y-4">
<div class="flex items-center gap-3 p-4 rounded-xl ${s.risk_level === 'HIGH' ? 'bg-red-50' : s.risk_level === 'MEDIUM' ? 'bg-amber-50' : 'bg-emerald-50'}">
<span class="text-3xl">${rc.icon}</span>
<div class="flex-1">
<p class="text-xl font-bold text-slate-800">${s.supplier_name}</p>
<p class="text-sm text-slate-600">${s.supplier_category}</p>
</div>
<div class="text-center">
<span class="inline-flex items-center justify-center w-14 h-14 rounded-full text-xl font-bold ${s.total_risk_score >= 80 ? 'bg-red-100 text-red-700' : s.total_risk_score >= 50 ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'}">
${s.total_risk_score}
</span>
<p class="text-xs text-slate-500 mt-1">风险评分</p>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold text-blue-600">${s.order_count}</p>
<p class="text-xs text-slate-500">订单数</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold text-emerald-600">${s.receipt_count}</p>
<p class="text-xs text-slate-500">收货数</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold text-red-600">${s.return_count || 0}</p>
<p class="text-xs text-slate-500">退货数</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold ${s.avg_delivery_days > 100 ? 'text-red-600' : 'text-slate-700'}">${s.avg_delivery_days}</p>
<p class="text-xs text-slate-500">平均交期(天)</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold ${s.quality_rate < 80 ? 'text-red-600' : s.quality_rate < 95 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(s.quality_rate)}</p>
<p class="text-xs text-slate-500">质量合格率</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-sm font-medium ${pc.bg} ${pc.color}">${pc.icon} ${pc.label}</span>
<p class="text-xs text-slate-500 mt-1">风险模式</p>
</div>
</div>
<div class="flex items-center gap-2 p-3 rounded-xl ${rc.light} ${rc.border} border">
<span class="text-lg">${rc.icon}</span>
<span class="font-medium ${rc.text}">风险等级: ${rc.label}</span>
</div>
</div>
`;
};
// 排名详情弹窗内容
const renderRankingDetail = (r) => {
const ac = getAssessment(r.risk_assessment);
return html`
<div class="space-y-4">
<div class="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-amber-50 to-orange-50">
<span class="w-12 h-12 flex items-center justify-center rounded-xl text-xl font-bold ${r.risk_rank <= 3 ? 'bg-gradient-to-br from-amber-400 to-orange-500 text-white' : 'bg-slate-100 text-slate-600'}">${r.risk_rank}</span>
<div class="flex-1">
<p class="text-xl font-bold text-slate-800">${r.supplier_name}</p>
<p class="text-sm text-slate-600">${r.supplier_category}</p>
</div>
<span class="px-3 py-1.5 rounded-lg text-sm font-medium ${ac.bg} ${ac.color}">${ac.label}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold text-blue-600">${r.order_count || 0}</p>
<p class="text-xs text-slate-500">订单数</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold text-emerald-600">${r.receipt_count || 0}</p>
<p class="text-xs text-slate-500">收货数</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold text-red-600">${r.return_count || 0}</p>
<p class="text-xs text-slate-500">退货数</p>
</div>
<div class="p-3 bg-slate-50 rounded-xl text-center">
<p class="text-2xl font-bold ${r.return_rate > 20 ? 'text-red-600' : r.return_rate > 10 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(r.return_rate)}</p>
<p class="text-xs text-slate-500">退货率</p>
</div>
</div>
</div>
`;
};
if (error) return html`
<div class="page-bg flex items-center justify-center p-6">
<div class="glass rounded-3xl shadow-2xl p-8 max-w-sm text-center border border-red-100">
<div class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-400 to-rose-500 rounded-2xl flex items-center justify-center text-3xl shadow-lg">⚠️</div>
<h3 class="text-lg font-bold text-red-700 mb-2">数据异常</h3>
<p class="text-sm text-red-500">${error}</p>
</div>
</div>`;
if (!processedData) return html`
<div class="page-bg flex items-center justify-center p-6">
<div class="glass rounded-3xl shadow-xl p-10 max-w-sm text-center border border-orange-100">
<div class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-orange-400 to-red-500 rounded-2xl flex items-center justify-center text-3xl animate-pulse">🔗</div>
<h3 class="text-lg font-bold text-orange-600 mb-2">加载中</h3>
<p class="text-sm text-slate-400">等待供应链数据...</p>
</div>
</div>`;
const { suppliers, highRiskOrders, categoryRisk, monthlyTrend, metrics, riskRanking, riskDistribution, patternStats } = processedData;
return html`
<div class="page-bg p-4 sm:p-5">
<div class="max-w-7xl mx-auto space-y-4">
<!-- 弹窗 -->
<${Modal} isOpen=${modalType === 'order'} onClose=${closeModal} title="订单详情" icon="📦">
${modalData && renderOrderDetail(modalData)}
<//>
<${Modal} isOpen=${modalType === 'supplier'} onClose=${closeModal} title="供应商详情" icon="🏭">
${modalData && renderSupplierDetail(modalData)}
<//>
<${Modal} isOpen=${modalType === 'ranking'} onClose=${closeModal} title="排名详情" icon="🏆">
${modalData && renderRankingDetail(modalData)}
<//>
<!-- 头部 -->
<div class="glass rounded-2xl p-5 shadow-lg border border-white/60">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 bg-gradient-to-br from-red-500 via-orange-500 to-amber-500 rounded-2xl flex items-center justify-center shadow-lg shadow-red-500/30">
<span class="text-white text-2xl">🔗</span>
</div>
<div>
<h1 class="text-2xl font-bold gradient-text">${config.title}</h1>
<p class="text-xs text-slate-500 mt-0.5">供应商监控 · 交期预警 · 质量追踪</p>
</div>
</div>
<div class="flex items-center gap-3 flex-wrap">
${(() => {
const highRisk = parseInt(metrics.high_risk_suppliers?.metric_value || 0);
const overdueOrders = parseInt(metrics.overdue_orders_30d?.metric_value || 0);
const recentReturns = parseInt(metrics.recent_returns_30d?.metric_value || 0);
return html`
${highRisk > 0 && html`
<span class="px-4 py-2 bg-red-50 border border-red-200 rounded-xl text-sm font-bold text-red-600 flex items-center gap-2 animate-pulse">
<span class="w-2.5 h-2.5 bg-red-500 rounded-full animate-blink"></span>
${highRisk} 高风险供应商
</span>
`}
${overdueOrders > 0 && html`
<span class="px-4 py-2 bg-orange-50 border border-orange-200 rounded-xl text-sm font-bold text-orange-600 flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-orange-500 rounded-full"></span>
${overdueOrders} 超期订单
</span>
`}
${recentReturns > 0 && html`
<span class="px-4 py-2 bg-purple-50 border border-purple-200 rounded-xl text-sm font-bold text-purple-600 flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-purple-500 rounded-full"></span>
${recentReturns} 近期退货
</span>
`}
`;
})()}
</div>
</div>
</div>
<!-- 核心指标卡片 -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
${[
{ key: 'high_risk_suppliers', icon: '🔴', label: '高风险供应商', color: 'from-red-500 to-rose-600', shadow: 'shadow-red-200/50', alert: true },
{ key: 'medium_risk_suppliers', icon: '🟡', label: '中风险供应商', color: 'from-amber-400 to-orange-500', shadow: 'shadow-amber-200/50' },
{ key: 'low_risk_suppliers', icon: '🟢', label: '低风险供应商', color: 'from-emerald-400 to-teal-500', shadow: 'shadow-emerald-200/50' },
{ key: 'pending_orders', icon: '📦', label: '待收货订单', color: 'from-blue-400 to-indigo-500', shadow: 'shadow-blue-200/50' },
{ key: 'overdue_orders_30d', icon: '⏰', label: '超期订单(30天)', color: 'from-orange-400 to-red-500', shadow: 'shadow-orange-200/50', alert: true },
{ key: 'recent_returns_30d', icon: '↩️', label: '近期退货(30天)', color: 'from-purple-400 to-pink-500', shadow: 'shadow-purple-200/50' }
].map((item, i) => {
const m = metrics[item.key] || {};
const isAlert = item.alert && parseInt(m.metric_value || 0) > 0;
const status = m.status;
return html`
<div key=${i} class="bg-white rounded-xl p-4 shadow-md border border-slate-100/80 hover:shadow-lg transition-all animate-slide-in ${isAlert ? 'ring-2 ring-red-200 bg-red-50/30' : ''}" style=${{ animationDelay: i * 50 + 'ms' }}>
<div class="flex items-center justify-between mb-2">
<span class="w-10 h-10 bg-gradient-to-br ${item.color} rounded-xl flex items-center justify-center text-base shadow-md ${item.shadow} ${isAlert ? 'animate-shake' : ''}">${item.icon}</span>
${status && status !== 'NORMAL' && html`
<span class="px-1.5 py-0.5 text-xs rounded ${status === 'ATTENTION_NEEDED' ? 'bg-red-100 text-red-600' : status === 'DELIVERY_WARNING' ? 'bg-orange-100 text-orange-600' : 'bg-slate-100 text-slate-600'}">
${status === 'ATTENTION_NEEDED' ? '需关注' : status === 'DELIVERY_WARNING' ? '交期预警' : status}
</span>
`}
</div>
<p class="text-2xl font-black ${isAlert ? 'text-red-600' : 'text-slate-800'}">${m.metric_value || '0'}</p>
<p class="text-xs text-slate-500 mt-0.5">${item.label}</p>
</div>
`;
})}
</div>
<!-- 图表区域 -->
<div class="grid lg:grid-cols-4 gap-4">
<!-- 风险分布 -->
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<span class="w-6 h-6 bg-gradient-to-br from-red-400 to-rose-500 rounded-lg flex items-center justify-center text-white text-xs">📊</span>
风险等级分布
</h3>
${Object.values(riskDistribution).some(v => v > 0)
? html`<div ref=${chartRef1} class="w-full"></div>`
: html`<${EmptyState} icon="📊" title="暂无风险数据" desc="等待数据加载" />`
}
</div>
<!-- 类别质量合格率 -->
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<span class="w-6 h-6 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg flex items-center justify-center text-white text-xs">📈</span>
类别质量合格率
</h3>
${categoryRisk.length > 0 ? html`
<div class="space-y-2 max-h-[180px] overflow-y-auto scrollbar-thin pr-1">
${[...categoryRisk].sort((a, b) => a.quality_rate - b.quality_rate).slice(0, 6).map((c, i) => {
const rc = getRisk(c.category_risk_level);
return html`
<div key=${i} class="flex items-center gap-2 p-2 rounded-lg ${c.category_risk_level === 'HIGH' ? 'bg-red-50' : 'bg-slate-50'} hover:shadow-sm transition-all">
<span class="w-2 h-2 rounded-full ${rc.dot}"></span>
<span class="flex-1 text-xs font-medium text-slate-700 truncate">${c.supplier_category}</span>
<div class="flex items-center gap-2 shrink-0">
<div class="w-16 h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all ${c.quality_rate < 50 ? 'bg-red-500' : c.quality_rate < 80 ? 'bg-orange-500' : c.quality_rate < 95 ? 'bg-amber-500' : 'bg-emerald-500'}" style=${{ width: Math.min(c.quality_rate, 100) + '%' }}></div>
</div>
<span class="text-xs font-bold w-12 text-right ${c.quality_rate < 50 ? 'text-red-600' : c.quality_rate < 80 ? 'text-orange-600' : c.quality_rate < 95 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(c.quality_rate)}</span>
</div>
</div>
`;
})}
</div>
` : html`<${EmptyState} icon="📈" title="暂无类别数据" desc="等待数据加载" />`}
</div>
<!-- 风险评分TOP -->
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<span class="w-6 h-6 bg-gradient-to-br from-purple-400 to-pink-500 rounded-lg flex items-center justify-center text-white text-xs"></span>
风险评分TOP
</h3>
${suppliers.filter(s => s.total_risk_score > 0).length > 0
? html`<div ref=${chartRef3} class="w-full"></div>`
: html`<${EmptyState} icon="⚡" title="暂无评分数据" desc="等待数据加载" />`
}
</div>
<!-- 月度趋势 -->
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<span class="w-6 h-6 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-lg flex items-center justify-center text-white text-xs">📉</span>
月度趋势
</h3>
${monthlyTrend.length > 0 ? html`
<div class="space-y-2">
${monthlyTrend.map((t, i) => html`
<div key=${i} class="p-3 rounded-xl bg-gradient-to-r from-slate-50 to-white border border-slate-100">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-bold text-slate-700">${t.month_start?.slice(0, 7) || '未知'}</span>
<span class="px-2 py-0.5 rounded text-xs font-medium ${t.return_rate > 20 ? 'bg-red-100 text-red-600' : t.return_rate > 10 ? 'bg-amber-100 text-amber-600' : 'bg-emerald-100 text-emerald-600'}">
退货率 ${formatPercent(t.return_rate)}
</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="text-center p-1.5 bg-blue-50 rounded">
<p class="text-blue-600 font-bold">${t.receipt_count}</p>
<p class="text-slate-500">收货数</p>
</div>
<div class="text-center p-1.5 bg-orange-50 rounded">
<p class="text-orange-600 font-bold">${t.avg_delivery_days?.toFixed(0) || 0}天</p>
<p class="text-slate-500">平均交期</p>
</div>
<div class="text-center p-1.5 bg-red-50 rounded">
<p class="text-red-600 font-bold">${t.return_count}</p>
<p class="text-slate-500">退货数</p>
</div>
</div>
</div>
`)}
</div>
` : html`<${EmptyState} icon="📉" title="暂无趋势数据" desc="等待数据加载" />`}
</div>
</div>
<!-- 高风险订单预警 - 简化卡片,点击查看详情 -->
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-slate-700 flex items-center gap-2">
<span class="w-6 h-6 bg-gradient-to-br from-red-400 to-rose-500 rounded-lg flex items-center justify-center text-white text-xs animate-blink">🚨</span>
高风险订单预警
<span class="text-xs text-slate-400 font-normal ml-2">点击卡片查看详情</span>
</h3>
<span class="px-2 py-1 bg-red-50 text-red-600 rounded-full text-xs font-medium">${highRiskOrders.length} 项</span>
</div>
${highRiskOrders.length > 0 ? html`
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
${paginatedOrders.map((order, i) => {
const rc = getRisk(order.risk_level);
const ac = getAction(order.action_required);
return html`
<div key=${i} onClick=${() => openModal('order', order)}
class="clickable-card bg-gradient-to-r ${order.risk_level === 'HIGH' ? 'from-red-50 to-orange-50 border-red-100' : 'from-amber-50 to-yellow-50 border-amber-100'} rounded-xl p-3 border animate-slide-in"
style=${{ animationDelay: i * 60 + 'ms' }}>
<div class="flex items-center gap-2 mb-2">
<span class="text-lg">${rc.icon}</span>
<span class="font-bold text-slate-800 text-sm truncate flex-1">${order.purchase_order_number}</span>
</div>
<p class="text-xs text-slate-600 truncate mb-1">${order.supplier_name}</p>
<div class="flex items-center justify-between">
<span class="text-xs text-orange-600">已等 ${order.days_since_order} 天</span>
<span class="text-xs text-slate-400">点击详情 →</span>
</div>
</div>
`;
})}
</div>
${orderTotalPages > 1 && html`
<div class="mt-3 flex items-center justify-center gap-2">
<button onClick=${() => setOrderPage(p => Math.max(1, p - 1))} disabled=${orderPage === 1}
class="px-3 py-1 text-xs rounded-lg border border-slate-200 hover:bg-slate-50 disabled:opacity-50 transition-colors">上一页</button>
<span class="text-xs text-slate-500">${orderPage}/${orderTotalPages}</span>
<button onClick=${() => setOrderPage(p => Math.min(orderTotalPages, p + 1))} disabled=${orderPage === orderTotalPages}
class="px-3 py-1 text-xs rounded-lg border border-slate-200 hover:bg-slate-50 disabled:opacity-50 transition-colors">下一页</button>
</div>
`}
` : html`<${EmptyState} icon="✨" title="暂无高风险订单" desc="供应链运行正常" />`}
</div>