feat(mfg-data-agent): 添加HTML可视化仪表盘和优化项目配置
- 新增6个HTML可视化仪表盘组件用于数据展示 * 人效产值损耗三维模型仪表盘 * 指标趋势分析与拐点预警仪表盘 * 一页式决策简报仪表盘 * 订单延迟预警分析仪表盘 * 供应链风险预警仪表盘 * 工单执行进度与异常节点仪表盘 - 添加VSCode工作区配置文件 - 更新businessQueries.json业务查询配置 - 优化api_client.py API客户端实现 - 更新pyproject.toml项目依赖版本 - 重组SQL查询文件结构 - 删除v2版本冗余文档配置 - 添加v2版本技能清单文档 - 更新日志文件记录
This commit is contained in:
@@ -0,0 +1,696 @@
|
||||
<!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; } }
|
||||
.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; }
|
||||
.glass { backdrop-filter: blur(16px); background: rgba(255,255,255,0.85); }
|
||||
.gradient-text { background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.page-bg { background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 30%, #f1f5f9 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; }
|
||||
</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('[WorkOrderProgress] 库加载失败:', 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: [[], [], [], [], [], []] };
|
||||
|
||||
// 状态映射配置 OPEN→PENDING, STARTED→IN_PROGRESS, CLOSED→COMPLETED
|
||||
const statusMap = {
|
||||
'OPEN': 'PENDING', 'STARTED': 'IN_PROGRESS', 'CLOSED': 'COMPLETED',
|
||||
'PENDING': 'PENDING', 'IN_PROGRESS': 'IN_PROGRESS', 'COMPLETED': 'COMPLETED'
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
'PENDING': { label: '待处理', bg: 'from-amber-400 to-orange-500', light: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200', icon: '⏳', dot: 'bg-amber-400' },
|
||||
'IN_PROGRESS': { label: '进行中', bg: 'from-blue-400 to-indigo-500', light: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', icon: '🔄', dot: 'bg-blue-500' },
|
||||
'COMPLETED': { 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 riskConfig = {
|
||||
'HIGH': { label: '高风险', bg: 'from-red-500 to-rose-600', light: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', icon: '🔴' },
|
||||
'MEDIUM': { label: '中风险', bg: 'from-amber-400 to-orange-500', light: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200', icon: '🟡' },
|
||||
'LOW': { label: '低风险', bg: 'from-blue-400 to-cyan-500', light: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-200', icon: '🔵' },
|
||||
'NONE': { label: '无风险', bg: 'from-slate-300 to-slate-400', light: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-200', icon: '⚪' }
|
||||
};
|
||||
|
||||
const anomalyConfig = {
|
||||
'SCHEDULE_DELAY': { label: '进度延迟', icon: '⏰', action: '调整排期', color: 'text-red-600' },
|
||||
'MATERIAL_SHORTAGE': { label: '物料短缺', icon: '📦', action: '补充物料', color: 'text-orange-600' },
|
||||
'LABOR_STALLED': { label: '人力停滞', icon: '👷', action: '检查人力', color: 'text-amber-600' },
|
||||
'EQUIPMENT_FAULT': { label: '设备故障', icon: '🔧', action: '维修设备', color: 'text-purple-600' },
|
||||
'QUALITY_ISSUE': { label: '质量问题', icon: '🔍', action: '质量检查', color: 'text-pink-600' }
|
||||
};
|
||||
|
||||
const actionConfig = {
|
||||
'ADJUST_SCHEDULE': '调整排期', 'REPLENISH_MATERIAL': '补充物料', 'CHECK_LABOR': '检查人力',
|
||||
'REPAIR_EQUIPMENT': '维修设备', 'QUALITY_CHECK': '质量检查'
|
||||
};
|
||||
|
||||
// 空状态组件
|
||||
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>
|
||||
`;
|
||||
|
||||
function ChartApp() {
|
||||
const [rawData, setRawData] = useState(defaultData);
|
||||
const [pageHelper, setPageHelper] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [filterRisk, setFilterRisk] = useState('all');
|
||||
const pageSize = 10;
|
||||
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 mapStatus = (status) => statusMap[status] || status;
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
if (!rawData.data || rawData.data.length < 6) return null;
|
||||
|
||||
const [workOrders = [], statusSummary = [], anomalyNodes = [], categorySummary = [], timeline = [], metrics = []] = rawData.data;
|
||||
|
||||
// 映射工单状态
|
||||
const mappedWorkOrders = workOrders.map(wo => ({
|
||||
...wo,
|
||||
status_name: mapStatus(wo.status_name)
|
||||
}));
|
||||
|
||||
// 核心指标处理
|
||||
const metricsMap = {};
|
||||
metrics.forEach(m => { metricsMap[m.metric] = m; });
|
||||
|
||||
// 状态汇总处理
|
||||
const statusStats = {};
|
||||
statusSummary.forEach(s => {
|
||||
const mapped = mapStatus(s.status_name);
|
||||
if (!statusStats[mapped]) statusStats[mapped] = { count: 0, planned: 0, completed: 0, pct: 0 };
|
||||
statusStats[mapped].count += parseInt(s.order_count || 0);
|
||||
statusStats[mapped].planned += s.total_planned || 0;
|
||||
statusStats[mapped].completed += s.total_completed || 0;
|
||||
statusStats[mapped].pct += s.pct || 0;
|
||||
});
|
||||
|
||||
// 异常节点处理
|
||||
const anomalyList = anomalyNodes.map(a => ({
|
||||
...a,
|
||||
anomalyInfo: anomalyConfig[a.anomaly_type] || { label: a.anomaly_type, icon: '⚠️', action: '检查', color: 'text-slate-600' }
|
||||
}));
|
||||
|
||||
// 按类别汇总
|
||||
const categoryStats = categorySummary.map(c => ({
|
||||
...c,
|
||||
pending: parseInt(c.pending || 0),
|
||||
in_progress: parseInt(c.in_progress || 0),
|
||||
completed: parseInt(c.completed || 0)
|
||||
}));
|
||||
|
||||
// 时间线处理
|
||||
const timelineData = timeline.map(t => ({
|
||||
...t,
|
||||
status_name: mapStatus(t.status_name)
|
||||
}));
|
||||
|
||||
// 风险分布统计
|
||||
const riskStats = { HIGH: 0, MEDIUM: 0, LOW: 0, NONE: 0 };
|
||||
mappedWorkOrders.forEach(wo => {
|
||||
const risk = wo.risk_level || 'NONE';
|
||||
if (riskStats[risk] !== undefined) riskStats[risk]++;
|
||||
});
|
||||
|
||||
return {
|
||||
workOrders: mappedWorkOrders,
|
||||
statusSummary: statusStats,
|
||||
anomalyNodes: anomalyList,
|
||||
categorySummary: categoryStats,
|
||||
timeline: timelineData,
|
||||
metrics: metricsMap,
|
||||
riskStats
|
||||
};
|
||||
}, [rawData]);
|
||||
|
||||
// 过滤后的工单列表
|
||||
const filteredWorkOrders = useMemo(() => {
|
||||
if (!processedData) return [];
|
||||
let list = processedData.workOrders;
|
||||
if (filterStatus !== 'all') list = list.filter(wo => wo.status_name === filterStatus);
|
||||
if (filterRisk !== 'all') list = list.filter(wo => wo.risk_level === filterRisk);
|
||||
return list;
|
||||
}, [processedData, filterStatus, filterRisk]);
|
||||
|
||||
// 分页数据
|
||||
const paginatedWorkOrders = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredWorkOrders.slice(start, start + pageSize);
|
||||
}, [filteredWorkOrders, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredWorkOrders.length / 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.keys(processedData.statusSummary).length > 0) {
|
||||
const statusData = Object.entries(processedData.statusSummary).map(([status, v]) => ({
|
||||
name: statusConfig[status]?.label || status,
|
||||
value: v.count
|
||||
}));
|
||||
|
||||
chartInstance1.current = new G2.Chart({ container: chartRef1.current, autoFit: true, height: 200 });
|
||||
chartInstance1.current.coordinate({ type: 'theta', innerRadius: 0.6 });
|
||||
chartInstance1.current.interval()
|
||||
.data(statusData)
|
||||
.transform({ type: 'stackY' })
|
||||
.encode('y', 'value').encode('color', 'name')
|
||||
.scale('color', { range: ['#f59e0b', '#3b82f6', '#10b981'] })
|
||||
.style('stroke', '#fff').style('lineWidth', 2)
|
||||
.label({ text: d => d.value > 0 ? d.name : '', position: 'outside', fontSize: 10 })
|
||||
.legend('color', { position: 'bottom', layout: { justifyContent: 'center' } })
|
||||
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 单' }] });
|
||||
chartInstance1.current.render();
|
||||
}
|
||||
|
||||
// 类别完成率柱状图
|
||||
if (chartRef2.current && processedData.categorySummary.length > 0) {
|
||||
const categoryData = processedData.categorySummary.map(c => ({
|
||||
name: c.product_category.length > 5 ? c.product_category.slice(0, 5) + '..' : c.product_category,
|
||||
完成率: c.completion_rate
|
||||
}));
|
||||
|
||||
chartInstance2.current = new G2.Chart({ container: chartRef2.current, autoFit: true, height: 200 });
|
||||
chartInstance2.current.interval()
|
||||
.data(categoryData)
|
||||
.encode('x', 'name').encode('y', '完成率').encode('color', 'name')
|
||||
.style('radius', 6)
|
||||
.axis('x', { labelAutoRotate: true, labelFontSize: 10 })
|
||||
.axis('y', { title: false, labelFormatter: v => v + '%' })
|
||||
.legend(false)
|
||||
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v.toFixed(1) + '%' }] });
|
||||
chartInstance2.current.render();
|
||||
}
|
||||
|
||||
// 风险分布饼图
|
||||
if (chartRef3.current && Object.values(processedData.riskStats).some(v => v > 0)) {
|
||||
const riskData = Object.entries(processedData.riskStats)
|
||||
.filter(([_, v]) => v > 0)
|
||||
.map(([risk, count]) => ({
|
||||
name: riskConfig[risk]?.label || risk,
|
||||
value: count
|
||||
}));
|
||||
|
||||
chartInstance3.current = new G2.Chart({ container: chartRef3.current, autoFit: true, height: 200 });
|
||||
chartInstance3.current.coordinate({ type: 'theta', innerRadius: 0.5 });
|
||||
chartInstance3.current.interval()
|
||||
.data(riskData)
|
||||
.transform({ type: 'stackY' })
|
||||
.encode('y', 'value').encode('color', 'name')
|
||||
.scale('color', { range: ['#ef4444', '#f59e0b', '#3b82f6', '#94a3b8'] })
|
||||
.style('stroke', '#fff').style('lineWidth', 2)
|
||||
.label({ text: 'name', position: 'outside', fontSize: 10 })
|
||||
.legend('color', { position: 'bottom', layout: { justifyContent: 'center' } })
|
||||
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 单' }] });
|
||||
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 formatPercent = v => (v || 0).toFixed(1) + '%';
|
||||
const getStatus = status => statusConfig[status] || statusConfig['PENDING'];
|
||||
const getRisk = risk => riskConfig[risk] || riskConfig['NONE'];
|
||||
|
||||
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-blue-100">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-2xl flex items-center justify-center text-3xl animate-pulse">📋</div>
|
||||
<h3 class="text-lg font-bold text-blue-600 mb-2">加载中</h3>
|
||||
<p class="text-sm text-slate-400">等待工单数据...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const { statusSummary, anomalyNodes, categorySummary, timeline, metrics, riskStats } = processedData;
|
||||
|
||||
return html`
|
||||
<div class="page-bg p-4 sm:p-5">
|
||||
<div class="max-w-7xl mx-auto space-y-4">
|
||||
|
||||
<!-- 头部 -->
|
||||
<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-blue-500 via-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg shadow-indigo-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 anomalyCount = parseInt(metrics.anomaly_count?.value || 0);
|
||||
const pendingCount = parseInt(metrics.pending?.value || 0);
|
||||
const inProgressCount = parseInt(metrics.in_progress?.value || 0);
|
||||
return html`
|
||||
${anomalyCount > 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>
|
||||
${anomalyCount} 个异常
|
||||
</span>
|
||||
`}
|
||||
${pendingCount > 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>
|
||||
${pendingCount} 待处理
|
||||
</span>
|
||||
`}
|
||||
${inProgressCount > 0 && html`
|
||||
<span class="px-4 py-2 bg-blue-50 border border-blue-200 rounded-xl text-sm font-bold text-blue-600 flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 bg-blue-500 rounded-full"></span>
|
||||
${inProgressCount} 进行中
|
||||
</span>
|
||||
`}
|
||||
`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 核心指标卡片 -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
${[
|
||||
{ key: 'total_orders', icon: '📦', label: '总工单', color: 'from-slate-500 to-slate-600', shadow: 'shadow-slate-200/50' },
|
||||
{ key: 'pending', icon: '⏳', label: '待处理', color: 'from-amber-400 to-orange-500', shadow: 'shadow-amber-200/50' },
|
||||
{ key: 'in_progress', icon: '🔄', label: '进行中', color: 'from-blue-400 to-indigo-500', shadow: 'shadow-blue-200/50' },
|
||||
{ key: 'completed', icon: '✅', label: '已完成', color: 'from-emerald-400 to-teal-500', shadow: 'shadow-emerald-200/50' },
|
||||
{ key: 'completion_rate', icon: '📊', label: '完成率', color: 'from-purple-400 to-pink-500', shadow: 'shadow-purple-200/50' },
|
||||
{ key: 'anomaly_count', icon: '⚠️', label: '异常数', color: 'from-red-400 to-rose-500', shadow: 'shadow-red-200/50', alert: true },
|
||||
{ key: 'today_completed', icon: '🎯', label: '今日完成', color: 'from-cyan-400 to-blue-500', shadow: 'shadow-cyan-200/50' }
|
||||
].map((item, i) => {
|
||||
const m = metrics[item.key] || {};
|
||||
const isAlert = item.alert && parseInt(m.value || 0) > 0;
|
||||
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-pulse' : ''}">${item.icon}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-black ${isAlert ? 'text-red-600' : 'text-slate-800'}">${m.value || '0'}</p>
|
||||
<p class="text-xs text-slate-500 mt-0.5">${item.label}</p>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- 主体内容区 -->
|
||||
<div class="grid lg:grid-cols-3 gap-4">
|
||||
|
||||
<!-- 左侧:状态分布 + 风险分布 -->
|
||||
<div class="space-y-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-blue-400 to-indigo-500 rounded-lg flex items-center justify-center text-white text-xs">📊</span>
|
||||
状态分布
|
||||
</h3>
|
||||
${Object.keys(statusSummary).length > 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-red-400 to-rose-500 rounded-lg flex items-center justify-center text-white text-xs">⚡</span>
|
||||
风险分布
|
||||
</h3>
|
||||
${Object.values(riskStats).some(v => v > 0)
|
||||
? html`<div ref=${chartRef3} class="w-full"></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>
|
||||
异常节点预警
|
||||
</h3>
|
||||
<span class="px-2 py-1 bg-red-50 text-red-600 rounded-full text-xs font-medium">${anomalyNodes.length} 项</span>
|
||||
</div>
|
||||
${anomalyNodes.length > 0 ? html`
|
||||
<div class="space-y-2 max-h-[420px] overflow-y-auto scrollbar-thin pr-1">
|
||||
${anomalyNodes.map((a, i) => html`
|
||||
<div key=${i} class="bg-gradient-to-r from-red-50 to-orange-50 rounded-xl p-3 border border-red-100 hover:shadow-md transition-all animate-slide-in" style=${{ animationDelay: i * 60 + 'ms' }}>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-lg">${a.anomalyInfo.icon}</span>
|
||||
<span class="font-bold text-slate-800 text-sm truncate">${a.work_order_number}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-600 truncate mb-2">${a.product_name}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-white/80 rounded text-xs ${a.anomalyInfo.color} font-medium">${a.anomalyInfo.label}</span>
|
||||
<span class="px-2 py-0.5 bg-white/80 rounded text-xs text-slate-500">${formatPercent(a.completion_rate)} 完成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-xs text-slate-400 mb-1">${a.elapsed_hours?.toFixed(1) || 0}h</p>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-gradient-to-r from-orange-400 to-red-500 text-white rounded-lg text-xs font-medium shadow-sm">
|
||||
${actionConfig[a.action] || a.action}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</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-emerald-400 to-teal-500 rounded-lg flex items-center justify-center text-white text-xs">📈</span>
|
||||
类别完成率
|
||||
</h3>
|
||||
${categorySummary.length > 0 ? html`
|
||||
<div ref=${chartRef2} class="w-full mb-3"></div>
|
||||
<div class="space-y-2 max-h-[200px] overflow-y-auto scrollbar-thin pr-1">
|
||||
${categorySummary.map((c, i) => html`
|
||||
<div key=${i} class="flex items-center justify-between p-2 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="w-2 h-2 rounded-full bg-gradient-to-r from-indigo-400 to-purple-500"></span>
|
||||
<span class="text-xs font-medium text-slate-700 truncate">${c.product_category}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<div class="flex gap-1 text-xs">
|
||||
<span class="text-amber-600">${c.pending}</span>
|
||||
<span class="text-slate-300">/</span>
|
||||
<span class="text-blue-600">${c.in_progress}</span>
|
||||
<span class="text-slate-300">/</span>
|
||||
<span class="text-emerald-600">${c.completed}</span>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 bg-emerald-50 text-emerald-600 rounded text-xs font-bold">${formatPercent(c.completion_rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : html`<${EmptyState} icon="📈" title="暂无类别数据" desc="等待数据加载" />`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工单明细表 -->
|
||||
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div class="flex flex-wrap items-center justify-between gap-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-indigo-400 to-purple-500 rounded-lg flex items-center justify-center text-white text-xs">📋</span>
|
||||
工单明细
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 状态筛选 -->
|
||||
<select value=${filterStatus} onChange=${e => { setFilterStatus(e.target.value); setCurrentPage(1); }}
|
||||
class="px-3 py-1.5 text-xs border border-slate-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-indigo-200">
|
||||
<option value="all">全部状态</option>
|
||||
<option value="PENDING">待处理</option>
|
||||
<option value="IN_PROGRESS">进行中</option>
|
||||
<option value="COMPLETED">已完成</option>
|
||||
</select>
|
||||
<!-- 风险筛选 -->
|
||||
<select value=${filterRisk} onChange=${e => { setFilterRisk(e.target.value); setCurrentPage(1); }}
|
||||
class="px-3 py-1.5 text-xs border border-slate-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-indigo-200">
|
||||
<option value="all">全部风险</option>
|
||||
<option value="HIGH">高风险</option>
|
||||
<option value="MEDIUM">中风险</option>
|
||||
<option value="LOW">低风险</option>
|
||||
<option value="NONE">无风险</option>
|
||||
</select>
|
||||
<span class="px-2 py-1 bg-indigo-50 text-indigo-600 rounded-full text-xs font-medium">${filteredWorkOrders.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${filteredWorkOrders.length > 0 ? html`
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2.5 text-left text-xs font-semibold text-slate-500">工单号</th>
|
||||
<th class="px-3 py-2.5 text-left text-xs font-semibold text-slate-500">产品</th>
|
||||
<th class="px-3 py-2.5 text-center text-xs font-semibold text-slate-500">状态</th>
|
||||
<th class="px-3 py-2.5 text-center text-xs font-semibold text-slate-500">进度</th>
|
||||
<th class="px-3 py-2.5 text-center text-xs font-semibold text-slate-500">计划/完成</th>
|
||||
<th class="px-3 py-2.5 text-center text-xs font-semibold text-slate-500">风险</th>
|
||||
<th class="px-3 py-2.5 text-center text-xs font-semibold text-slate-500">异常</th>
|
||||
<th class="px-3 py-2.5 text-right text-xs font-semibold text-slate-500">工时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
${paginatedWorkOrders.map((wo, i) => {
|
||||
const sc = getStatus(wo.status_name);
|
||||
const rc = getRisk(wo.risk_level);
|
||||
const hasAnomaly = wo.anomaly_flags && wo.anomaly_flags.length > 0;
|
||||
const anomalyInfo = hasAnomaly ? (anomalyConfig[wo.anomaly_flags] || { label: wo.anomaly_flags, icon: '⚠️', color: 'text-slate-600' }) : null;
|
||||
return html`
|
||||
<tr key=${i} class="hover:bg-slate-50/50 transition-colors ${hasAnomaly ? 'bg-red-50/30' : ''}">
|
||||
<td class="px-3 py-2.5">
|
||||
<span class="font-semibold text-slate-800 text-sm">${wo.work_order_number}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<p class="text-slate-700 text-sm truncate max-w-[160px]">${wo.product_name}</p>
|
||||
<p class="text-xs text-slate-400">${wo.product_category}</p>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-center">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${sc.light} ${sc.text}">
|
||||
${sc.icon} ${sc.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-indigo-400 to-purple-500 rounded-full transition-all" style=${{ width: Math.min(wo.completion_rate || 0, 100) + '%' }}></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-slate-600 w-12 text-right">${formatPercent(wo.completion_rate)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-center">
|
||||
<span class="text-sm text-slate-700">${wo.completed_qty || 0}<span class="text-slate-400">/</span>${wo.planned_qty || 0}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-center">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${rc.light} ${rc.text}">
|
||||
${rc.icon} ${rc.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-center">
|
||||
${hasAnomaly ? html`
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-red-50 rounded-lg text-xs font-medium ${anomalyInfo.color}">
|
||||
${anomalyInfo.icon} ${anomalyInfo.label}
|
||||
</span>
|
||||
` : html`<span class="text-slate-400 text-xs">-</span>`}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-right">
|
||||
<span class="text-sm font-medium text-slate-600">${(wo.total_work_hours || 0).toFixed(1)}h</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
${totalPages > 1 && html`
|
||||
<div class="px-4 py-3 border-t border-slate-100 flex items-center justify-between">
|
||||
<p class="text-xs text-slate-500">共 ${filteredWorkOrders.length} 条,第 ${currentPage}/${totalPages} 页</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button onClick=${() => setCurrentPage(p => Math.max(1, p - 1))} disabled=${currentPage === 1}
|
||||
class="px-3 py-1.5 text-xs rounded-lg border border-slate-200 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">上一页</button>
|
||||
${Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1;
|
||||
if (totalPages > 5) {
|
||||
if (currentPage <= 3) page = i + 1;
|
||||
else if (currentPage >= totalPages - 2) page = totalPages - 4 + i;
|
||||
else page = currentPage - 2 + i;
|
||||
}
|
||||
return html`
|
||||
<button key=${page} onClick=${() => setCurrentPage(page)}
|
||||
class="w-8 h-8 text-xs rounded-lg border transition-colors ${currentPage === page
|
||||
? 'bg-gradient-to-r from-indigo-500 to-purple-500 text-white border-transparent shadow-md'
|
||||
: 'border-slate-200 hover:bg-slate-50'}">${page}</button>
|
||||
`;
|
||||
})}
|
||||
<button onClick=${() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled=${currentPage === totalPages}
|
||||
class="px-3 py-1.5 text-xs rounded-lg border border-slate-200 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
` : html`<${EmptyState} icon="📋" title="暂无工单数据" desc="调整筛选条件或等待数据加载" />`}
|
||||
</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-cyan-400 to-blue-500 rounded-lg flex items-center justify-center text-white text-xs">⏱️</span>
|
||||
执行时间线
|
||||
</h3>
|
||||
<span class="px-2 py-1 bg-cyan-50 text-cyan-600 rounded-full text-xs font-medium">${timeline.length} 条记录</span>
|
||||
</div>
|
||||
${timeline.length > 0 ? html`
|
||||
<div class="relative">
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gradient-to-b from-indigo-200 via-purple-200 to-pink-200"></div>
|
||||
<div class="space-y-3 max-h-[300px] overflow-y-auto scrollbar-thin pr-2">
|
||||
${timeline.slice(0, 15).map((t, i) => {
|
||||
const sc = getStatus(t.status_name);
|
||||
return html`
|
||||
<div key=${i} class="relative pl-10 animate-slide-in" style=${{ animationDelay: i * 40 + 'ms' }}>
|
||||
<div class="absolute left-2.5 w-3 h-3 rounded-full ${sc.dot} ring-4 ring-white shadow-sm"></div>
|
||||
<div class="bg-gradient-to-r from-slate-50 to-white rounded-xl p-3 border border-slate-100 hover:shadow-md transition-all">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-bold text-slate-800 text-sm">${t.work_order_number}</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-xs font-medium ${sc.light} ${sc.text}">${sc.label}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-600 truncate">${t.product_name}</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-xs text-slate-400">${t.start_time}</p>
|
||||
<p class="text-xs font-medium text-indigo-600">${formatPercent(t.completion_rate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>计划: ${t.planned_qty}</span>
|
||||
<span>完成: ${t.completed_qty}</span>
|
||||
<span>耗时: ${(t.duration_hours || 0).toFixed(1)}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
` : html`<${EmptyState} icon="⏱️" title="暂无时间线数据" desc="等待数据加载" />`}
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="text-center pt-2 pb-3">
|
||||
<p class="text-xs text-slate-400">
|
||||
<span class="inline-flex items-center gap-1.5 px-4 py-2 glass rounded-full border border-white/50 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>
|
||||
Reference in New Issue
Block a user