- 新增6个HTML可视化仪表盘组件用于数据展示 * 人效产值损耗三维模型仪表盘 * 指标趋势分析与拐点预警仪表盘 * 一页式决策简报仪表盘 * 订单延迟预警分析仪表盘 * 供应链风险预警仪表盘 * 工单执行进度与异常节点仪表盘 - 添加VSCode工作区配置文件 - 更新businessQueries.json业务查询配置 - 优化api_client.py API客户端实现 - 更新pyproject.toml项目依赖版本 - 重组SQL查询文件结构 - 删除v2版本冗余文档配置 - 添加v2版本技能清单文档 - 更新日志文件记录
792 lines
45 KiB
HTML
792 lines
45 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 shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
|
||
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
|
||
.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-bounce { animation: bounce 1s 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; }
|
||
.card-hover { transition: all 0.3s ease; }
|
||
.card-hover:hover { transform: translateY(-4px); box-shadow: 0 20px 40px -12px rgba(0,0,0,0.15); }
|
||
.trend-arrow { display: inline-flex; align-items: center; justify-content: center; }
|
||
.shimmer-bg { background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); background-size: 200% 100%; animation: shimmer 2s infinite; }
|
||
</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('[MetricTrend] 库加载失败:', error); }
|
||
});
|
||
|
||
function initReactApp() {
|
||
const { useState, useEffect, useMemo, useRef, useCallback } = React;
|
||
const html = htm.bind(React.createElement);
|
||
|
||
const config = { enableLog: true, title: '指标趋势分析与拐点预警' };
|
||
const lzwcaiComInitDate = '{{lzwcaiComInitDate}}';
|
||
const defaultData = { success: false, data: [[], [], [], []] };
|
||
|
||
// 趋势配置
|
||
const trendConfig = {
|
||
'RISING': { label: '上升', icon: '📈', arrow: '↑', color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-200', gradient: 'from-emerald-400 to-teal-500' },
|
||
'FALLING': { label: '下降', icon: '📉', arrow: '↓', color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200', gradient: 'from-red-400 to-rose-500' },
|
||
'STABLE': { label: '平稳', icon: '➡️', arrow: '→', color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-200', gradient: 'from-blue-400 to-indigo-500' },
|
||
'NONE': { label: '无数据', icon: '⚪', arrow: '-', color: 'text-slate-500', bg: 'bg-slate-50', border: 'border-slate-200', gradient: 'from-slate-400 to-slate-500' }
|
||
};
|
||
|
||
// 状态配置
|
||
const statusConfig = {
|
||
'NORMAL': { label: '正常', icon: '✅', color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-200' },
|
||
'ANOMALY': { label: '异常', icon: '⚠️', color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' }
|
||
};
|
||
|
||
// 拐点配置
|
||
const turningPointConfig = {
|
||
'TURNING_POINT': { label: '拐点', icon: '🔄', color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-200' },
|
||
'UPWARD': { label: '向上拐点', icon: '⬆️', color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-200' },
|
||
'DOWNWARD': { label: '向下拐点', icon: '⬇️', color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' },
|
||
'NONE': { label: '无拐点', icon: '➖', color: 'text-slate-500', bg: 'bg-slate-50', border: 'border-slate-200' }
|
||
};
|
||
|
||
// 建议配置
|
||
const recommendationConfig = {
|
||
'MAINTAIN_CURRENT_MEASURES': { label: '维持当前措施', icon: '✅', color: 'text-emerald-600' },
|
||
'INCREASE_MONITORING': { label: '加强监控', icon: '👁️', color: 'text-amber-600' },
|
||
'TAKE_ACTION': { label: '采取行动', icon: '🚨', color: 'text-red-600' }
|
||
};
|
||
|
||
// 空状态组件
|
||
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 TrendArrow = ({ trend, size = 'md' }) => {
|
||
const cfg = trendConfig[trend] || trendConfig['NONE'];
|
||
const sizeClass = size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-sm' : 'text-lg';
|
||
return html`<span class="trend-arrow ${cfg.color} ${sizeClass} font-bold">${cfg.arrow}</span>`;
|
||
};
|
||
|
||
// 指标卡片组件(可交互)
|
||
const MetricCard = ({ title, value, unit, trend, status, icon, gradient, onClick, isActive, subValue, subLabel }) => {
|
||
const trendCfg = trendConfig[trend] || trendConfig['NONE'];
|
||
const statusCfg = statusConfig[status] || statusConfig['NORMAL'];
|
||
const isAnomaly = status === 'ANOMALY';
|
||
|
||
return html`
|
||
<div
|
||
onClick=${onClick}
|
||
class="bg-white rounded-2xl p-4 shadow-md border border-slate-100/80 card-hover cursor-pointer relative overflow-hidden
|
||
${isActive ? 'ring-2 ring-indigo-400 ring-offset-2' : ''}
|
||
${isAnomaly ? 'ring-2 ring-red-200' : ''}"
|
||
>
|
||
${isAnomaly && html`<div class="absolute top-0 right-0 w-20 h-20 bg-red-500/10 rounded-bl-full"></div>`}
|
||
<div class="flex items-start justify-between mb-3">
|
||
<span class="w-10 h-10 bg-gradient-to-br ${gradient} rounded-xl flex items-center justify-center text-lg shadow-md ${isAnomaly ? 'animate-pulse' : ''}">${icon}</span>
|
||
<div class="flex items-center gap-1">
|
||
<${TrendArrow} trend=${trend} size="sm" />
|
||
<span class="text-xs font-medium ${trendCfg.color}">${trendCfg.label}</span>
|
||
</div>
|
||
</div>
|
||
<p class="text-2xl font-black text-slate-800">${value}<span class="text-sm font-normal text-slate-400 ml-1">${unit}</span></p>
|
||
<p class="text-xs text-slate-500 mt-1">${title}</p>
|
||
${subValue !== undefined && html`
|
||
<div class="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between">
|
||
<span class="text-xs text-slate-400">${subLabel}</span>
|
||
<span class="text-xs font-medium text-slate-600">${subValue}</span>
|
||
</div>
|
||
`}
|
||
${isAnomaly && html`
|
||
<div class="mt-2 flex items-center gap-1">
|
||
<span class="w-2 h-2 bg-red-500 rounded-full animate-blink"></span>
|
||
<span class="text-xs font-medium text-red-600">需要关注</span>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
|
||
function ChartApp() {
|
||
const [rawData, setRawData] = useState(defaultData);
|
||
const [pageHelper, setPageHelper] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
const [activeMetric, setActiveMetric] = useState('efficiency'); // efficiency, output, defect
|
||
const [selectedDate, setSelectedDate] = useState(null);
|
||
const [showDetail, setShowDetail] = useState(false);
|
||
|
||
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 processedData = useMemo(() => {
|
||
if (!rawData.data || rawData.data.length < 4) return null;
|
||
|
||
const [dailyMetrics = [], weeklyMetrics = [], summaryMetrics = [], turningPoints = []] = rawData.data;
|
||
|
||
// 日指标数据处理(按日期排序)
|
||
const sortedDaily = [...dailyMetrics].sort((a, b) => new Date(a.metric_date) - new Date(b.metric_date));
|
||
|
||
// 周指标数据处理
|
||
const sortedWeekly = [...weeklyMetrics].sort((a, b) => new Date(a.week_start) - new Date(b.week_start));
|
||
|
||
// 汇总指标
|
||
const summaryMap = {};
|
||
summaryMetrics.forEach(m => { summaryMap[m.metric_name] = m; });
|
||
|
||
// 统计异常和拐点数量
|
||
let anomalyCount = 0;
|
||
let turningPointCount = 0;
|
||
|
||
sortedDaily.forEach(d => {
|
||
if (d.efficiency_status === 'ANOMALY') anomalyCount++;
|
||
if (d.output_status === 'ANOMALY') anomalyCount++;
|
||
if (d.defect_status === 'ANOMALY') anomalyCount++;
|
||
if (d.efficiency_turning_point === 'TURNING_POINT') turningPointCount++;
|
||
if (d.output_turning_point === 'TURNING_POINT') turningPointCount++;
|
||
if (d.defect_turning_point === 'TURNING_POINT') turningPointCount++;
|
||
});
|
||
|
||
// 获取最新数据
|
||
const latestDaily = sortedDaily.length > 0 ? sortedDaily[sortedDaily.length - 1] : null;
|
||
const latestWeekly = sortedWeekly.length > 0 ? sortedWeekly[sortedWeekly.length - 1] : null;
|
||
|
||
return {
|
||
dailyMetrics: sortedDaily,
|
||
weeklyMetrics: sortedWeekly,
|
||
summaryMetrics: summaryMap,
|
||
turningPoints,
|
||
anomalyCount,
|
||
turningPointCount,
|
||
latestDaily,
|
||
latestWeekly
|
||
};
|
||
}, [rawData]);
|
||
|
||
// 渲染趋势图表
|
||
useEffect(() => {
|
||
if (!processedData || !window.G2) return;
|
||
|
||
// 清理旧图表
|
||
[chartInstance1, chartInstance2, chartInstance3].forEach(ref => {
|
||
if (ref.current) { ref.current.destroy(); ref.current = null; }
|
||
});
|
||
|
||
const { dailyMetrics, weeklyMetrics } = processedData;
|
||
|
||
// 人效趋势图(折线图 + 移动平均线)
|
||
if (chartRef1.current && dailyMetrics.length > 0) {
|
||
const efficiencyData = [];
|
||
dailyMetrics.forEach(d => {
|
||
efficiencyData.push({ date: d.metric_date, value: d.hourly_output, type: '实际人效' });
|
||
efficiencyData.push({ date: d.metric_date, value: d.efficiency_ma7, type: '7日均线' });
|
||
});
|
||
|
||
chartInstance1.current = new G2.Chart({ container: chartRef1.current, autoFit: true, height: 220 });
|
||
chartInstance1.current.line()
|
||
.data(efficiencyData)
|
||
.encode('x', 'date').encode('y', 'value').encode('color', 'type')
|
||
.scale('color', { range: ['#3b82f6', '#f59e0b'] })
|
||
.style('lineWidth', 2)
|
||
.axis('x', { labelAutoRotate: true, labelFontSize: 10, title: false })
|
||
.axis('y', { title: false, labelFormatter: v => v.toFixed(1) })
|
||
.legend('color', { position: 'top', layout: { justifyContent: 'center' } })
|
||
.tooltip({ shared: true });
|
||
|
||
// 标记拐点
|
||
const turningPointData = dailyMetrics.filter(d => d.efficiency_turning_point === 'TURNING_POINT');
|
||
if (turningPointData.length > 0) {
|
||
chartInstance1.current.point()
|
||
.data(turningPointData.map(d => ({ date: d.metric_date, value: d.hourly_output })))
|
||
.encode('x', 'date').encode('y', 'value')
|
||
.style('fill', '#ef4444').style('r', 6)
|
||
.tooltip({ items: [{ channel: 'y', name: '拐点', valueFormatter: v => v.toFixed(2) }] });
|
||
}
|
||
|
||
chartInstance1.current.render();
|
||
}
|
||
|
||
// 产量趋势图
|
||
if (chartRef2.current && dailyMetrics.length > 0) {
|
||
const outputData = [];
|
||
dailyMetrics.forEach(d => {
|
||
outputData.push({ date: d.metric_date, value: d.daily_output, type: '日产量' });
|
||
outputData.push({ date: d.metric_date, value: d.output_ma7, type: '7日均线' });
|
||
});
|
||
|
||
chartInstance2.current = new G2.Chart({ container: chartRef2.current, autoFit: true, height: 220 });
|
||
chartInstance2.current.line()
|
||
.data(outputData)
|
||
.encode('x', 'date').encode('y', 'value').encode('color', 'type')
|
||
.scale('color', { range: ['#10b981', '#8b5cf6'] })
|
||
.style('lineWidth', 2)
|
||
.axis('x', { labelAutoRotate: true, labelFontSize: 10, title: false })
|
||
.axis('y', { title: false })
|
||
.legend('color', { position: 'top', layout: { justifyContent: 'center' } })
|
||
.tooltip({ shared: true });
|
||
|
||
// 标记拐点
|
||
const turningPointData = dailyMetrics.filter(d => d.output_turning_point === 'TURNING_POINT');
|
||
if (turningPointData.length > 0) {
|
||
chartInstance2.current.point()
|
||
.data(turningPointData.map(d => ({ date: d.metric_date, value: d.daily_output })))
|
||
.encode('x', 'date').encode('y', 'value')
|
||
.style('fill', '#ef4444').style('r', 6);
|
||
}
|
||
|
||
chartInstance2.current.render();
|
||
}
|
||
|
||
// 周环比趋势图(柱状图)
|
||
if (chartRef3.current && weeklyMetrics.length > 0) {
|
||
const weeklyData = weeklyMetrics.map(w => ({
|
||
week: w.week_start,
|
||
人效: w.hourly_output,
|
||
环比: w.efficiency_wow_pct || 0
|
||
}));
|
||
|
||
chartInstance3.current = new G2.Chart({ container: chartRef3.current, autoFit: true, height: 220 });
|
||
chartInstance3.current.interval()
|
||
.data(weeklyData)
|
||
.encode('x', 'week').encode('y', '人效')
|
||
.encode('color', d => d['环比'] >= 0 ? '上升' : '下降')
|
||
.scale('color', { range: ['#10b981', '#ef4444'] })
|
||
.style('radius', 6)
|
||
.axis('x', { labelAutoRotate: true, labelFontSize: 10, title: false })
|
||
.axis('y', { title: false })
|
||
.legend('color', { position: 'top', layout: { justifyContent: 'center' } })
|
||
.tooltip({ items: [
|
||
{ channel: 'y', name: '人效', valueFormatter: v => v.toFixed(2) },
|
||
{ field: '环比', name: '环比', valueFormatter: v => (v >= 0 ? '+' : '') + v.toFixed(1) + '%' }
|
||
] });
|
||
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 !== null && v !== undefined ? (v >= 0 ? '+' : '') + v.toFixed(1) + '%' : '-';
|
||
const formatNumber = v => v !== null && v !== undefined ? v.toFixed(2) : '-';
|
||
const getTrend = trend => trendConfig[trend] || trendConfig['NONE'];
|
||
const getStatus = status => statusConfig[status] || statusConfig['NORMAL'];
|
||
const getTurningPoint = tp => turningPointConfig[tp] || turningPointConfig['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 { dailyMetrics, weeklyMetrics, summaryMetrics, turningPoints, anomalyCount, turningPointCount, latestDaily, latestWeekly } = 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-indigo-500 via-purple-500 to-pink-500 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">
|
||
${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>
|
||
`}
|
||
${turningPointCount > 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>
|
||
${turningPointCount} 个拐点
|
||
</span>
|
||
`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 核心指标卡片(可点击切换) -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
${latestDaily && html`
|
||
<${MetricCard}
|
||
title="人效(件/小时)"
|
||
value=${formatNumber(latestDaily.hourly_output)}
|
||
unit=""
|
||
trend=${latestDaily.efficiency_trend}
|
||
status=${latestDaily.efficiency_status}
|
||
icon="⚡"
|
||
gradient="from-blue-400 to-indigo-500"
|
||
onClick=${() => setActiveMetric('efficiency')}
|
||
isActive=${activeMetric === 'efficiency'}
|
||
subValue=${formatNumber(latestDaily.efficiency_ma7)}
|
||
subLabel="7日均线"
|
||
/>
|
||
<${MetricCard}
|
||
title="日产量"
|
||
value=${latestDaily.daily_output}
|
||
unit="件"
|
||
trend=${latestDaily.output_trend}
|
||
status=${latestDaily.output_status}
|
||
icon="📦"
|
||
gradient="from-emerald-400 to-teal-500"
|
||
onClick=${() => setActiveMetric('output')}
|
||
isActive=${activeMetric === 'output'}
|
||
subValue=${formatNumber(latestDaily.output_ma7)}
|
||
subLabel="7日均线"
|
||
/>
|
||
<${MetricCard}
|
||
title="废品率"
|
||
value=${(latestDaily.defect_rate * 100).toFixed(2)}
|
||
unit="%"
|
||
trend=${latestDaily.defect_trend}
|
||
status=${latestDaily.defect_status}
|
||
icon="🔍"
|
||
gradient="from-purple-400 to-pink-500"
|
||
onClick=${() => setActiveMetric('defect')}
|
||
isActive=${activeMetric === 'defect'}
|
||
subValue=${(latestDaily.defect_rate_ma7 * 100).toFixed(2) + '%'}
|
||
subLabel="7日均线"
|
||
/>
|
||
`}
|
||
${latestWeekly && html`
|
||
<${MetricCard}
|
||
title="周环比变化"
|
||
value=${formatPercent(latestWeekly.efficiency_wow_pct)}
|
||
unit=""
|
||
trend=${latestWeekly.efficiency_trend}
|
||
status=${latestWeekly.efficiency_wow_pct < -20 ? 'ANOMALY' : 'NORMAL'}
|
||
icon="📈"
|
||
gradient="from-amber-400 to-orange-500"
|
||
subValue=${latestWeekly.worker_count + ' 人'}
|
||
subLabel="本周人数"
|
||
/>
|
||
`}
|
||
</div>
|
||
|
||
<!-- 趋势图表区域 -->
|
||
<div class="grid lg:grid-cols-2 gap-4">
|
||
<!-- 人效趋势图 -->
|
||
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4 card-hover">
|
||
<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-blue-400 to-indigo-500 rounded-lg flex items-center justify-center text-white text-xs">⚡</span>
|
||
人效趋势分析
|
||
</h3>
|
||
<div class="flex items-center gap-2">
|
||
<span class="w-3 h-3 bg-blue-500 rounded-full"></span>
|
||
<span class="text-xs text-slate-500">实际值</span>
|
||
<span class="w-3 h-3 bg-amber-500 rounded-full ml-2"></span>
|
||
<span class="text-xs text-slate-500">均线</span>
|
||
<span class="w-3 h-3 bg-red-500 rounded-full ml-2"></span>
|
||
<span class="text-xs text-slate-500">拐点</span>
|
||
</div>
|
||
</div>
|
||
${dailyMetrics.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 card-hover">
|
||
<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-emerald-400 to-teal-500 rounded-lg flex items-center justify-center text-white text-xs">📦</span>
|
||
产量趋势分析
|
||
</h3>
|
||
<div class="flex items-center gap-2">
|
||
<span class="w-3 h-3 bg-emerald-500 rounded-full"></span>
|
||
<span class="text-xs text-slate-500">日产量</span>
|
||
<span class="w-3 h-3 bg-purple-500 rounded-full ml-2"></span>
|
||
<span class="text-xs text-slate-500">均线</span>
|
||
</div>
|
||
</div>
|
||
${dailyMetrics.length > 0
|
||
? html`<div ref=${chartRef2} class="w-full"></div>`
|
||
: html`<${EmptyState} icon="📦" title="暂无产量数据" desc="等待数据加载" />`
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 周数据分析 + 拐点预警 -->
|
||
<div class="grid lg:grid-cols-3 gap-4">
|
||
<!-- 周环比趋势 -->
|
||
<div class="lg:col-span-2 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-amber-400 to-orange-500 rounded-lg flex items-center justify-center text-white text-xs">📊</span>
|
||
周人效环比分析
|
||
</h3>
|
||
<span class="px-2 py-1 bg-amber-50 text-amber-600 rounded-full text-xs font-medium">${weeklyMetrics.length} 周</span>
|
||
</div>
|
||
${weeklyMetrics.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">
|
||
<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-pulse">🔄</span>
|
||
拐点预警
|
||
</h3>
|
||
<span class="px-2 py-1 bg-red-50 text-red-600 rounded-full text-xs font-medium">${turningPoints.length} 项</span>
|
||
</div>
|
||
${turningPoints.length > 0 ? html`
|
||
<div class="space-y-2 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
|
||
${turningPoints.map((tp, i) => {
|
||
const tpCfg = getTurningPoint(tp.turning_type);
|
||
const recCfg = recommendationConfig[tp.recommendation] || { label: tp.recommendation, icon: '📋', color: 'text-slate-600' };
|
||
return html`
|
||
<div key=${i} class="bg-gradient-to-r from-amber-50 to-orange-50 rounded-xl p-3 border border-amber-100 hover:shadow-md transition-all card-hover 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">${tpCfg.icon}</span>
|
||
<span class="font-bold text-slate-800 text-sm">${tp.metric_date}</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-1 mb-2">
|
||
<span class="px-2 py-0.5 ${tpCfg.bg} ${tpCfg.border} border rounded text-xs ${tpCfg.color} font-medium">${tpCfg.label}</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||
<span>人效: ${formatNumber(tp.daily_efficiency)}</span>
|
||
<span>均线: ${formatNumber(tp.ma7_line)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="text-right shrink-0">
|
||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-white/80 rounded-lg text-xs font-medium ${recCfg.color}">
|
||
${recCfg.icon} ${recCfg.label}
|
||
</span>
|
||
</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-4">
|
||
<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>
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
${[
|
||
{ key: 'efficiency_per_hour', label: '人效(件/小时)', icon: '⚡', gradient: 'from-blue-400 to-indigo-500' },
|
||
{ key: 'daily_output', label: '日产量', icon: '📦', gradient: 'from-emerald-400 to-teal-500' },
|
||
{ key: 'defect_rate_pct', label: '废品率', icon: '🔍', gradient: 'from-purple-400 to-pink-500' }
|
||
].map((item, i) => {
|
||
const m = summaryMetrics[item.key] || {};
|
||
const trendCfg = getTrend(m.trend);
|
||
const isWarning = m.warning === 'ANOMALY';
|
||
return html`
|
||
<div key=${i} class="bg-gradient-to-br from-slate-50 to-white rounded-xl p-4 border border-slate-100 hover:shadow-md transition-all card-hover ${isWarning ? 'ring-2 ring-red-200' : ''}">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="w-8 h-8 bg-gradient-to-br ${item.gradient} rounded-lg flex items-center justify-center text-sm shadow-md">${item.icon}</span>
|
||
<span class="text-sm font-medium text-slate-700">${item.label}</span>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<p class="text-xs text-slate-400 mb-1">近7日均值</p>
|
||
<p class="text-lg font-bold text-slate-800">${m.last_7d_avg !== null ? formatNumber(m.last_7d_avg) : '-'}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-xs text-slate-400 mb-1">前7日均值</p>
|
||
<p class="text-lg font-bold text-slate-600">${m.prev_7d_avg !== null ? formatNumber(m.prev_7d_avg) : '-'}</p>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 pt-3 border-t border-slate-100 flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<${TrendArrow} trend=${m.trend} size="sm" />
|
||
<span class="text-xs font-medium ${trendCfg.color}">${trendCfg.label}</span>
|
||
</div>
|
||
<span class="text-sm font-bold ${m.change_rate_pct >= 0 ? 'text-emerald-600' : 'text-red-600'}">
|
||
${m.change_rate_pct !== null ? formatPercent(m.change_rate_pct) : '-'}
|
||
</span>
|
||
</div>
|
||
${isWarning && html`
|
||
<div class="mt-2 flex items-center gap-1">
|
||
<span class="w-2 h-2 bg-red-500 rounded-full animate-blink"></span>
|
||
<span class="text-xs font-medium text-red-600">需要关注</span>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
})}
|
||
</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-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">${dailyMetrics.length} 条</span>
|
||
</div>
|
||
</div>
|
||
${dailyMetrics.length > 0 ? html`
|
||
<div class="overflow-x-auto max-h-[400px] overflow-y-auto scrollbar-thin">
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-slate-50 sticky top-0">
|
||
<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-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-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>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-slate-100">
|
||
${dailyMetrics.slice().reverse().map((d, i) => {
|
||
const effTrend = getTrend(d.efficiency_trend);
|
||
const outTrend = getTrend(d.output_trend);
|
||
const effStatus = getStatus(d.efficiency_status);
|
||
const outStatus = getStatus(d.output_status);
|
||
const hasTurningPoint = d.efficiency_turning_point === 'TURNING_POINT' || d.output_turning_point === 'TURNING_POINT';
|
||
const hasAnomaly = d.efficiency_status === 'ANOMALY' || d.output_status === 'ANOMALY';
|
||
|
||
return html`
|
||
<tr key=${i} class="hover:bg-slate-50/50 transition-colors ${hasAnomaly ? 'bg-red-50/30' : ''} ${hasTurningPoint ? 'bg-amber-50/30' : ''}">
|
||
<td class="px-3 py-2.5">
|
||
<span class="font-semibold text-slate-800 text-sm">${d.metric_date}</span>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-center">
|
||
<div>
|
||
<span class="font-bold text-slate-800">${formatNumber(d.hourly_output)}</span>
|
||
<p class="text-xs text-slate-400">均线: ${formatNumber(d.efficiency_ma7)}</p>
|
||
</div>
|
||
</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 ${effTrend.bg} ${effTrend.color}">
|
||
<${TrendArrow} trend=${d.efficiency_trend} size="sm" />
|
||
${effTrend.label}
|
||
</span>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-center">
|
||
<div>
|
||
<span class="font-bold text-slate-800">${d.daily_output}</span>
|
||
<p class="text-xs text-slate-400">均线: ${formatNumber(d.output_ma7)}</p>
|
||
</div>
|
||
</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 ${outTrend.bg} ${outTrend.color}">
|
||
<${TrendArrow} trend=${d.output_trend} size="sm" />
|
||
${outTrend.label}
|
||
</span>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-center">
|
||
<span class="text-sm text-slate-700">${(d.defect_rate * 100).toFixed(2)}%</span>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-center">
|
||
${hasTurningPoint ? html`
|
||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-amber-50 border border-amber-200 rounded-lg text-xs font-medium text-amber-600 animate-pulse">
|
||
🔄 拐点
|
||
</span>
|
||
` : html`<span class="text-slate-400 text-xs">-</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 border border-red-200 rounded-lg text-xs font-medium text-red-600">
|
||
⚠️ 异常
|
||
</span>
|
||
` : html`
|
||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-emerald-50 border border-emerald-200 rounded-lg text-xs font-medium text-emerald-600">
|
||
✅ 正常
|
||
</span>
|
||
`}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
` : html`<${EmptyState} icon="📅" title="暂无日指标数据" desc="等待数据加载" />`}
|
||
</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">
|
||
<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-violet-400 to-purple-500 rounded-lg flex items-center justify-center text-white text-xs">📆</span>
|
||
周指标汇总
|
||
</h3>
|
||
</div>
|
||
${weeklyMetrics.length > 0 ? html`
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 p-4">
|
||
${weeklyMetrics.slice().reverse().map((w, i) => {
|
||
const effTrend = getTrend(w.efficiency_trend);
|
||
const outTrend = getTrend(w.output_trend);
|
||
const isPositive = w.efficiency_wow_pct >= 0;
|
||
return html`
|
||
<div key=${i} class="bg-gradient-to-br from-slate-50 to-white rounded-xl p-4 border border-slate-100 hover:shadow-lg transition-all card-hover animate-slide-in" style=${{ animationDelay: i * 80 + 'ms' }}>
|
||
<div class="flex items-center justify-between mb-3">
|
||
<span class="text-sm font-bold text-slate-800">${w.week_start}</span>
|
||
<span class="px-2 py-1 ${isPositive ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'} rounded-lg text-xs font-bold">
|
||
${formatPercent(w.efficiency_wow_pct)}
|
||
</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-500">人效</span>
|
||
<div class="flex items-center gap-1">
|
||
<span class="text-sm font-bold text-slate-700">${formatNumber(w.hourly_output)}</span>
|
||
<${TrendArrow} trend=${w.efficiency_trend} size="sm" />
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-500">总产量</span>
|
||
<span class="text-sm font-medium text-slate-600">${w.total_output}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-500">总工时</span>
|
||
<span class="text-sm font-medium text-slate-600">${w.total_hours}h</span>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-500">人数</span>
|
||
<span class="text-sm font-medium text-slate-600">${w.worker_count} 人</span>
|
||
</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>
|