Files
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

627 lines
36 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 slideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.05); } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.animate-slide-up { animation: slideUp 0.5s ease-out forwards; }
.animate-fade-in { animation: fadeIn 0.4s ease-out forwards; }
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
.animate-blink { animation: blink 1.5s ease-in-out infinite; }
.glass { backdrop-filter: blur(20px); background: rgba(255,255,255,0.92); }
.gradient-text { background: linear-gradient(135deg, #f59e0b 0%, #ef4444 50%, #dc2626 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.page-bg { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 25%, #fcd34d 50%, #fbbf24 75%, #f59e0b 100%); min-height: 100vh; }
.card-shadow { box-shadow: 0 4px 24px rgba(245, 158, 11, 0.15), 0 2px 8px rgba(0,0,0,0.08); }
.warning-red { background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border-color: #fca5a5; }
.warning-yellow { background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); border-color: #fcd34d; }
.warning-green { background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); border-color: #86efac; }
.table-scroll { max-height: 480px; overflow-y: auto; }
.table-scroll::-webkit-scrollbar { width: 8px; }
.table-scroll::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 4px; }
.table-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.table-scroll::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</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('[OrderDelayWarning] 库加载失败:', 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 warningConfig = {
'RED': { label: '高风险', bg: 'bg-red-500', light: 'bg-red-50', text: 'text-red-600', border: 'border-red-200', icon: '🔴', gradient: 'from-red-500 to-rose-600' },
'YELLOW': { label: '中风险', bg: 'bg-amber-500', light: 'bg-amber-50', text: 'text-amber-600', border: 'border-amber-200', icon: '🟡', gradient: 'from-amber-500 to-orange-500' },
'GREEN': { label: '低风险', bg: 'bg-emerald-500', light: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-200', icon: '🟢', gradient: 'from-emerald-500 to-teal-500' }
};
// 风险因素配置
const riskFactorConfig = {
'NORMAL': { label: '正常', icon: '✅' },
'PRODUCTION_DELAY': { label: '生产延迟', icon: '🏭' },
'LOGISTICS_DELAY': { label: '物流延误', icon: '🚚' },
'EQUIPMENT_FAULT': { label: '设备故障', icon: '⚙️' },
'QUALITY_ISSUE': { label: '质量问题', icon: '🔍' },
'HIGH_DEFECT': { label: '高缺陷率', icon: '⚠️' },
'HIGH_SCRAP': { label: '高报废率', icon: '🗑️' },
'WORK_ORDER_LAG': { label: '工单滞后', icon: '📋' }
};
// 空状态组件
const EmptyState = ({ icon, title, desc }) => html`
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-amber-100 to-orange-100 rounded-2xl flex items-center justify-center text-3xl mb-4 border border-amber-200">${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 Pagination = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return null;
const pages = [];
const showPages = 5;
let start = Math.max(1, current - Math.floor(showPages / 2));
let end = Math.min(totalPages, start + showPages - 1);
if (end - start < showPages - 1) start = Math.max(1, end - showPages + 1);
for (let i = start; i <= end; i++) pages.push(i);
return html`
<div class="flex items-center justify-between px-5 py-4 border-t border-slate-100 bg-slate-50/50">
<span class="text-xs text-slate-500">共 ${total} 条记录,第 ${current}/${totalPages} 页</span>
<div class="flex items-center gap-1">
<button onClick=${() => current > 1 && onChange(current - 1)} disabled=${current <= 1}
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${current <= 1 ? 'bg-slate-100 text-slate-400 cursor-not-allowed' : 'bg-white text-slate-600 hover:bg-amber-50 hover:text-amber-600 border border-slate-200'}">
上一页
</button>
${pages.map(p => html`
<button key=${p} onClick=${() => onChange(p)}
class="w-8 h-8 rounded-lg text-xs font-medium transition-all ${p === current ? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-md' : 'bg-white text-slate-600 hover:bg-amber-50 border border-slate-200'}">
${p}
</button>
`)}
<button onClick=${() => current < totalPages && onChange(current + 1)} disabled=${current >= totalPages}
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${current >= totalPages ? 'bg-slate-100 text-slate-400 cursor-not-allowed' : 'bg-white text-slate-600 hover:bg-amber-50 hover:text-amber-600 border border-slate-200'}">
下一页
</button>
</div>
</div>
`;
};
function ChartApp() {
const [rawData, setRawData] = useState(defaultData);
const [pageHelper, setPageHelper] = useState(null);
const [error, setError] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('delay_probability_pct');
const [sortOrder, setSortOrder] = useState('desc');
const pageSize = 15;
const chartRef1 = useRef(null);
const chartRef2 = useRef(null);
const chartRef3 = useRef(null);
const chartInstance1 = useRef(null);
const chartInstance2 = 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 safeArray = (arr) => Array.isArray(arr) ? arr : [];
const safeNumber = (val, def = 0) => {
const n = parseFloat(val);
return isNaN(n) ? def : n;
};
const processedData = useMemo(() => {
if (!rawData.data || rawData.data.length < 1) return null;
const orders = safeArray(rawData.data[0]).map(o => ({
...o,
order_amount: safeNumber(o.order_amount),
avg_production_days: safeNumber(o.avg_production_days),
avg_logistics_delay_days: safeNumber(o.avg_logistics_delay_days),
historical_delay_count: safeNumber(o.historical_delay_count),
defect_rate_pct: safeNumber(o.defect_rate_pct),
scrap_rate_pct: safeNumber(o.scrap_rate_pct),
active_work_order_count: safeNumber(o.active_work_order_count),
lagging_work_order_count: safeNumber(o.lagging_work_order_count),
delay_probability_pct: safeNumber(o.delay_probability_pct)
}));
// 统计各预警等级数量
const warningStats = { RED: 0, YELLOW: 0, GREEN: 0 };
orders.forEach(o => {
const level = o.warning_level || 'GREEN';
if (warningStats[level] !== undefined) warningStats[level]++;
});
// 统计风险因素分布
const riskStats = {};
orders.forEach(o => {
const factor = o.primary_risk_factor || 'NORMAL';
if (!riskStats[factor]) riskStats[factor] = 0;
riskStats[factor]++;
});
// 按客户统计
const customerStats = {};
orders.forEach(o => {
const name = o.customer_name || '未知';
if (!customerStats[name]) customerStats[name] = { count: 0, amount: 0, redCount: 0, yellowCount: 0 };
customerStats[name].count++;
customerStats[name].amount += o.order_amount;
if (o.warning_level === 'RED') customerStats[name].redCount++;
if (o.warning_level === 'YELLOW') customerStats[name].yellowCount++;
});
// 汇总指标
const totalOrders = orders.length;
const totalAmount = orders.reduce((s, o) => s + o.order_amount, 0);
const avgDelayProb = totalOrders > 0 ? orders.reduce((s, o) => s + o.delay_probability_pct, 0) / totalOrders : 0;
const highRiskCount = warningStats.RED + warningStats.YELLOW;
return {
orders, warningStats, riskStats, customerStats,
totalOrders, totalAmount, avgDelayProb, highRiskCount
};
}, [rawData]);
// 排序后的订单
const sortedOrders = useMemo(() => {
if (!processedData) return [];
const sorted = [...processedData.orders];
sorted.sort((a, b) => {
let aVal = a[sortField], bVal = b[sortField];
if (typeof aVal === 'string') aVal = aVal.toLowerCase();
if (typeof bVal === 'string') bVal = bVal.toLowerCase();
if (sortOrder === 'asc') return aVal > bVal ? 1 : -1;
return aVal < bVal ? 1 : -1;
});
return sorted;
}, [processedData, sortField, sortOrder]);
// 分页数据
const pagedOrders = sortedOrders.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// 渲染图表
useEffect(() => {
if (!processedData || !window.G2) return;
// 清理旧图表
[chartInstance1, chartInstance2, chartInstance3].forEach(ref => {
if (ref.current) { ref.current.destroy(); ref.current = null; }
});
// 预警等级分布饼图
if (chartRef1.current && Object.values(processedData.warningStats).some(v => v > 0)) {
const pieData = [
{ name: '高风险', value: processedData.warningStats.RED, color: '#ef4444' },
{ name: '中风险', value: processedData.warningStats.YELLOW, color: '#f59e0b' },
{ name: '低风险', value: processedData.warningStats.GREEN, color: '#10b981' }
].filter(d => d.value > 0);
chartInstance1.current = new G2.Chart({ container: chartRef1.current, autoFit: true, height: 200 });
chartInstance1.current.coordinate({ type: 'theta', innerRadius: 0.6 });
chartInstance1.current.interval()
.data(pieData)
.transform({ type: 'stackY' })
.encode('y', 'value').encode('color', 'name')
.style('stroke', '#fff').style('lineWidth', 3)
.label({ text: d => d.name + '\n' + d.value + '单', position: 'outside', fontSize: 11, fill: '#64748b' })
.scale('color', { range: ['#ef4444', '#f59e0b', '#10b981'] })
.legend(false)
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 单' }] });
chartInstance1.current.render();
}
// 风险因素分布柱状图
if (chartRef2.current && Object.keys(processedData.riskStats).length > 0) {
const riskData = Object.entries(processedData.riskStats)
.map(([key, value]) => ({
factor: (riskFactorConfig[key] || { label: key }).label,
count: value
}))
.sort((a, b) => b.count - a.count);
chartInstance2.current = new G2.Chart({ container: chartRef2.current, autoFit: true, height: 200 });
chartInstance2.current.interval()
.data(riskData)
.encode('x', 'factor').encode('y', 'count').encode('color', 'factor')
.style('radius', 6)
.axis('x', { labelAutoRotate: true, labelFontSize: 10, title: false })
.axis('y', { title: false, labelFormatter: v => v + '单' })
.legend(false)
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 单' }] });
chartInstance2.current.render();
}
// 客户订单金额TOP5
if (chartRef3.current && Object.keys(processedData.customerStats).length > 0) {
const customerData = Object.entries(processedData.customerStats)
.map(([name, stats]) => ({ name: name.length > 8 ? name.slice(0, 8) + '..' : name, amount: stats.amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 6);
chartInstance3.current = new G2.Chart({ container: chartRef3.current, autoFit: true, height: 200 });
chartInstance3.current.interval()
.data(customerData)
.encode('x', 'name').encode('y', 'amount').encode('color', 'name')
.style('radius', 6)
.axis('x', { labelAutoRotate: true, labelFontSize: 10, title: false })
.axis('y', { title: false, labelFormatter: v => (v/10000).toFixed(0) + '万' })
.legend(false)
.scale('color', { range: ['#f59e0b', '#fb923c', '#fbbf24', '#fcd34d', '#fde68a', '#fef3c7'] })
.tooltip({ items: [{ channel: 'y', valueFormatter: v => '¥' + (v/10000).toFixed(2) + '万' }] });
chartInstance3.current.render();
}
return () => {
[chartInstance1, chartInstance2, 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 formatMoney = v => '¥' + Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 });
const formatWan = v => ((v || 0) / 10000).toFixed(2);
const getWarning = level => warningConfig[level] || warningConfig['GREEN'];
const getRiskFactor = factor => riskFactorConfig[factor] || { label: factor, icon: '❓' };
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('desc');
}
setCurrentPage(1);
};
const SortIcon = ({ field }) => {
if (sortField !== field) return html`<span class="text-slate-300 ml-1">↕</span>`;
return html`<span class="text-amber-500 ml-1">${sortOrder === 'asc' ? '↑' : '↓'}</span>`;
};
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-200">
<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-600 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-amber-200">
<div class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-amber-400 to-orange-500 rounded-2xl flex items-center justify-center text-3xl animate-pulse">📦</div>
<h3 class="text-lg font-bold text-amber-600 mb-2">加载中</h3>
<p class="text-sm text-slate-400">等待订单数据...</p>
</div>
</div>`;
const { warningStats, riskStats, customerStats, totalOrders, totalAmount, avgDelayProb, highRiskCount } = processedData;
return html`
<div class="page-bg p-4 sm:p-6">
<div class="max-w-7xl mx-auto space-y-5">
<!-- 头部 -->
<div class="glass rounded-2xl p-5 card-shadow 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-amber-500 via-orange-500 to-red-500 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-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">
${warningStats.RED > 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>
${warningStats.RED} 个高风险
</span>
`}
${warningStats.YELLOW > 0 && html`
<span class="px-4 py-2 bg-amber-50 border border-amber-200 rounded-xl text-sm font-bold text-amber-600 flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-amber-500 rounded-full"></span>
${warningStats.YELLOW} 个中风险
</span>
`}
</div>
</div>
</div>
<!-- 核心指标卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
${[
{ icon: '📦', label: '订单总数', value: totalOrders, unit: '单', color: 'from-blue-500 to-indigo-500', shadow: 'shadow-blue-200/50' },
{ icon: '💰', label: '订单总额', value: formatWan(totalAmount), unit: '万', color: 'from-emerald-500 to-teal-500', shadow: 'shadow-emerald-200/50' },
{ icon: '📊', label: '平均延迟概率', value: avgDelayProb.toFixed(1), unit: '%', color: 'from-purple-500 to-pink-500', shadow: 'shadow-purple-200/50' },
{ icon: '⚠️', label: '风险订单', value: highRiskCount, unit: '单', color: highRiskCount > 0 ? 'from-red-500 to-rose-500' : 'from-slate-400 to-slate-500', shadow: highRiskCount > 0 ? 'shadow-red-200/50' : 'shadow-slate-200/50' }
].map((item, i) => html`
<div key=${i} class="glass rounded-2xl p-5 card-shadow border border-white/60 hover:shadow-lg transition-all animate-slide-up" style=${{ animationDelay: i * 80 + 'ms' }}>
<div class="flex items-center justify-between mb-3">
<span class="w-11 h-11 bg-gradient-to-br ${item.color} rounded-xl flex items-center justify-center text-lg shadow-md ${item.shadow}">${item.icon}</span>
</div>
<p class="text-3xl font-black text-slate-800">${item.value}<span class="text-sm font-normal text-slate-400 ml-1">${item.unit}</span></p>
<p class="text-xs text-slate-500 mt-1">${item.label}</p>
</div>
`)}
</div>
<!-- 预警等级分布卡片 -->
<div class="grid grid-cols-3 gap-4">
${[
{ level: 'RED', count: warningStats.RED },
{ level: 'YELLOW', count: warningStats.YELLOW },
{ level: 'GREEN', count: warningStats.GREEN }
].map((item, i) => {
const wc = getWarning(item.level);
const pct = totalOrders > 0 ? (item.count / totalOrders * 100).toFixed(1) : 0;
return html`
<div key=${item.level} class="glass rounded-2xl p-5 card-shadow border ${wc.border} hover:shadow-lg transition-all animate-slide-up" style=${{ animationDelay: i * 60 + 'ms' }}>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="w-10 h-10 bg-gradient-to-br ${wc.gradient} rounded-xl flex items-center justify-center text-lg shadow-md">${wc.icon}</span>
<span class="font-bold ${wc.text}">${wc.label}</span>
</div>
<span class="text-2xl font-black ${wc.text}">${item.count}</span>
</div>
<div class="h-2 bg-slate-100 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r ${wc.gradient} rounded-full transition-all" style=${{ width: pct + '%' }}></div>
</div>
<p class="text-xs text-slate-400 mt-2 text-right">${pct}%</p>
</div>
`;
})}
</div>
<!-- 图表区域 -->
<div class="grid lg:grid-cols-3 gap-4">
<!-- 预警分布 -->
<div class="glass rounded-2xl card-shadow border border-white/60 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center gap-3">
<span class="w-8 h-8 bg-gradient-to-br from-amber-500 to-orange-500 rounded-lg flex items-center justify-center text-white text-sm">📊</span>
<h3 class="text-sm font-bold text-slate-700">预警等级分布</h3>
</div>
<div class="p-4">
${Object.values(warningStats).some(v => v > 0)
? html`<div ref=${chartRef1} class="w-full"></div>`
: html`<${EmptyState} icon="📊" title="暂无预警数据" desc="等待订单数据加载" />`
}
</div>
</div>
<!-- 风险因素分布 -->
<div class="glass rounded-2xl card-shadow border border-white/60 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center gap-3">
<span class="w-8 h-8 bg-gradient-to-br from-rose-500 to-pink-500 rounded-lg flex items-center justify-center text-white text-sm">⚠️</span>
<h3 class="text-sm font-bold text-slate-700">风险因素分布</h3>
</div>
<div class="p-4">
${Object.keys(riskStats).length > 0
? html`<div ref=${chartRef2} class="w-full"></div>`
: html`<${EmptyState} icon="⚠️" title="暂无风险数据" desc="等待风险因素数据加载" />`
}
</div>
</div>
<!-- 客户订单金额TOP -->
<div class="glass rounded-2xl card-shadow border border-white/60 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center gap-3">
<span class="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center text-white text-sm">👥</span>
<h3 class="text-sm font-bold text-slate-700">客户订单金额TOP</h3>
</div>
<div class="p-4">
${Object.keys(customerStats).length > 0
? html`<div ref=${chartRef3} class="w-full"></div>`
: html`<${EmptyState} icon="👥" title="暂无客户数据" desc="等待客户数据加载" />`
}
</div>
</div>
</div>
<!-- 订单明细表格 -->
<div class="glass rounded-2xl card-shadow border border-white/60 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-8 h-8 bg-gradient-to-br from-amber-500 to-orange-500 rounded-lg flex items-center justify-center text-white text-sm">📋</span>
<h3 class="text-sm font-bold text-slate-700">订单延迟预警明细</h3>
</div>
<span class="px-3 py-1 bg-amber-50 text-amber-600 rounded-full text-xs font-medium border border-amber-200">${totalOrders} 条记录</span>
</div>
${sortedOrders.length > 0 ? html`
<div class="table-scroll">
<table class="w-full text-sm">
<thead class="bg-slate-50 sticky top-0 z-10">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-500 cursor-pointer hover:text-amber-600" onClick=${() => handleSort('order_number')}>
订单号<${SortIcon} field="order_number" />
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-500 cursor-pointer hover:text-amber-600" onClick=${() => handleSort('customer_name')}>
客户<${SortIcon} field="customer_name" />
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500 cursor-pointer hover:text-amber-600" onClick=${() => handleSort('order_date')}>
订单日期<${SortIcon} field="order_date" />
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-500 cursor-pointer hover:text-amber-600" onClick=${() => handleSort('order_amount')}>
金额<${SortIcon} field="order_amount" />
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500">生产/物流</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500">缺陷/报废率</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500 cursor-pointer hover:text-amber-600" onClick=${() => handleSort('delay_probability_pct')}>
延迟概率<${SortIcon} field="delay_probability_pct" />
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500 cursor-pointer hover:text-amber-600" onClick=${() => handleSort('warning_level')}>
预警等级<${SortIcon} field="warning_level" />
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500">风险因素</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
${pagedOrders.map((o, i) => {
const wc = getWarning(o.warning_level);
const rf = getRiskFactor(o.primary_risk_factor);
return html`
<tr key=${o.order_number || i} class="hover:bg-amber-50/30 transition-colors animate-fade-in" style=${{ animationDelay: i * 30 + 'ms' }}>
<td class="px-4 py-3">
<span class="font-semibold text-slate-800">${o.order_number || '-'}</span>
</td>
<td class="px-4 py-3">
<p class="font-medium text-slate-700 truncate max-w-[160px]" title=${o.customer_name}>${o.customer_name || '-'}</p>
</td>
<td class="px-4 py-3 text-center text-slate-600">${o.order_date || '-'}</td>
<td class="px-4 py-3 text-right font-bold text-slate-800">${formatMoney(o.order_amount)}</td>
<td class="px-4 py-3 text-center">
<div class="flex flex-col items-center gap-0.5">
<span class="text-xs text-slate-500">🏭 ${o.avg_production_days}天</span>
<span class="text-xs text-slate-500">🚚 ${o.avg_logistics_delay_days}天</span>
</div>
</td>
<td class="px-4 py-3 text-center">
<div class="flex flex-col items-center gap-0.5">
<span class="text-xs ${o.defect_rate_pct > 5 ? 'text-red-500 font-bold' : 'text-slate-500'}">缺陷 ${o.defect_rate_pct.toFixed(1)}%</span>
<span class="text-xs ${o.scrap_rate_pct > 10 ? 'text-red-500 font-bold' : 'text-slate-500'}">报废 ${o.scrap_rate_pct.toFixed(1)}%</span>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold ${o.delay_probability_pct >= 50 ? 'bg-red-100 text-red-600' : o.delay_probability_pct >= 20 ? 'bg-amber-100 text-amber-600' : 'bg-emerald-100 text-emerald-600'}">
${o.delay_probability_pct.toFixed(1)}%
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold ${wc.light} ${wc.text} border ${wc.border}">
${wc.icon} ${wc.label}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 text-slate-600 rounded-lg text-xs" title=${rf.label}>
${rf.icon} ${rf.label}
</span>
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
<${Pagination} current=${currentPage} total=${sortedOrders.length} pageSize=${pageSize} onChange=${setCurrentPage} />
` : html`<${EmptyState} icon="📋" title="暂无订单数据" desc="等待订单数据加载" />`}
</div>
<!-- 客户风险汇总 -->
${Object.keys(customerStats).length > 0 && html`
<div class="glass rounded-2xl card-shadow border border-white/60 p-5">
<div class="flex items-center gap-3 mb-4">
<span class="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-lg flex items-center justify-center text-white text-sm">👥</span>
<h3 class="text-sm font-bold text-slate-700">客户订单风险汇总</h3>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
${Object.entries(customerStats)
.sort((a, b) => b[1].amount - a[1].amount)
.slice(0, 10)
.map(([name, stats], i) => html`
<div key=${name} class="bg-white rounded-xl p-4 border border-slate-100 hover:shadow-md transition-all animate-slide-up" style=${{ animationDelay: i * 40 + 'ms' }}>
<p class="text-xs text-slate-500 mb-1 truncate" title=${name}>${name}</p>
<p class="text-lg font-black text-slate-800">¥${formatWan(stats.amount)}<span class="text-xs text-slate-400">万</span></p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-slate-400">${stats.count}单</span>
${stats.redCount > 0 && html`<span class="px-1.5 py-0.5 bg-red-100 text-red-600 rounded text-xs font-bold">${stats.redCount}高</span>`}
${stats.yellowCount > 0 && html`<span class="px-1.5 py-0.5 bg-amber-100 text-amber-600 rounded text-xs font-bold">${stats.yellowCount}中</span>`}
</div>
</div>
`)}
</div>
</div>
`}
<!-- 底部 -->
<div class="text-center pt-3 pb-4">
<p class="text-xs text-slate-500">
<span class="inline-flex items-center gap-1.5 px-4 py-2 glass rounded-full border border-white/60 shadow-sm">
⏰ 订单延迟预警分析 · 红/黄/绿三级智能预警
</span>
</p>
</div>
</div>
</div>
`;
}
ReactDOM.render(React.createElement(ChartApp), document.getElementById('root'));
}
</script>
</head>
<body class="m-0 p-0">
<div id="root"></div>
</body>
</html>