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,543 @@
|
||||
<!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; margin: 0; padding: 0; }
|
||||
body { font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; background: #0a0f1a; color: #e2e8f0; min-height: 100vh; }
|
||||
|
||||
/* 动画 */
|
||||
@keyframes glow { 0%, 100% { box-shadow: 0 0 20px rgba(99, 102, 241, 0.3); } 50% { box-shadow: 0 0 40px rgba(99, 102, 241, 0.5); } }
|
||||
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes gradientFlow { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }
|
||||
|
||||
.animate-glow { animation: glow 3s ease-in-out infinite; }
|
||||
.animate-float { animation: float 4s ease-in-out infinite; }
|
||||
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
.animate-slide { animation: slideUp 0.6s ease-out forwards; }
|
||||
|
||||
/* 背景 */
|
||||
.page-bg {
|
||||
background: linear-gradient(135deg, #0a0f1a 0%, #111827 50%, #0f172a 100%);
|
||||
background-attachment: fixed;
|
||||
position: relative;
|
||||
}
|
||||
.page-bg::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: radial-gradient(ellipse at 20% 20%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(236, 72, 153, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(6, 182, 212, 0.05) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: linear-gradient(145deg, rgba(30, 41, 59, 0.8), rgba(15, 23, 42, 0.9));
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(20px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card:hover { border-color: rgba(99, 102, 241, 0.4); transform: translateY(-2px); }
|
||||
|
||||
/* 渐变文字 */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #c084fc 50%, #f472b6 100%);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: rgba(30, 41, 59, 0.5); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(99, 102, 241, 0.5); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(99, 102, 241, 0.7); }
|
||||
|
||||
/* 表格 */
|
||||
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
||||
.data-table th { padding: 12px 16px; text-align: left; font-size: 11px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; background: rgba(30, 41, 59, 0.5); border-bottom: 1px solid rgba(99, 102, 241, 0.1); }
|
||||
.data-table td { padding: 14px 16px; font-size: 13px; border-bottom: 1px solid rgba(51, 65, 85, 0.3); transition: background 0.2s; }
|
||||
.data-table tr:hover td { background: rgba(99, 102, 241, 0.05); }
|
||||
|
||||
/* 标签 */
|
||||
.tag { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
||||
.tag-green { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
|
||||
.tag-yellow { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
|
||||
.tag-red { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
|
||||
.tag-blue { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
|
||||
|
||||
/* 进度条 */
|
||||
.progress-bar { height: 6px; background: rgba(51, 65, 85, 0.5); border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, #6366f1, #a855f7); transition: width 0.5s ease; }
|
||||
|
||||
/* 分页 */
|
||||
.pagination { display: flex; align-items: center; gap: 8px; }
|
||||
.page-btn { padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 500; border: 1px solid rgba(99, 102, 241, 0.3); background: transparent; color: #94a3b8; cursor: pointer; transition: all 0.2s; }
|
||||
.page-btn:hover:not(:disabled) { background: rgba(99, 102, 241, 0.2); color: #e2e8f0; }
|
||||
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.page-btn.active { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; border-color: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="page-bg">
|
||||
<div id="root"></div>
|
||||
<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: 'g2', src: '/LzwcaiEmbedFrameFile/lib/g2@5.2.4.min.js', check: () => typeof window.G2 !== 'undefined' }
|
||||
]
|
||||
});
|
||||
await loader.loadAll();
|
||||
initApp();
|
||||
} catch (e) { console.error('加载失败:', e); }
|
||||
});
|
||||
|
||||
function initApp() {
|
||||
const { useState, useEffect, useMemo, useRef } = React;
|
||||
const html = htm.bind(React.createElement);
|
||||
const config = { title: '人效-产值-损耗三维模型仪表盘' };
|
||||
const lzwcaiComInitDate = '{{lzwcaiComInitDate}}';
|
||||
const defaultData = { success: false, data: [[], [], [], [], []] };
|
||||
|
||||
// 配置
|
||||
const warningCfg = {
|
||||
GREEN: { label: '正常', class: 'tag-green', icon: '●' },
|
||||
YELLOW: { label: '关注', class: 'tag-yellow', icon: '●' },
|
||||
RED: { label: '预警', class: 'tag-red', icon: '●' }
|
||||
};
|
||||
const lossCfg = {
|
||||
LOW: { label: '低', class: 'tag-green' },
|
||||
MEDIUM: { label: '中', class: 'tag-yellow' },
|
||||
HIGH: { label: '高', class: 'tag-red' }
|
||||
};
|
||||
|
||||
// 空状态
|
||||
const Empty = ({ icon, text }) => html`
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 20px', color: '#64748b' }}>
|
||||
<div style=${{ fontSize: '32px', marginBottom: '12px', opacity: 0.5 }}>${icon}</div>
|
||||
<div style=${{ fontSize: '13px' }}>${text}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 格式化
|
||||
const fmt = (v, d = 2) => v == null || isNaN(v) ? '-' : Number(v).toLocaleString('zh-CN', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const fmtPct = v => v == null || isNaN(v) ? '-' : Number(v).toFixed(1) + '%';
|
||||
const fmtMoney = v => v == null || isNaN(v) ? '-' : '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState(defaultData);
|
||||
const [error, setError] = useState(null);
|
||||
const [workerPage, setWorkerPage] = useState(1);
|
||||
const [productPage, setProductPage] = useState(1);
|
||||
const [deptFilter, setDeptFilter] = useState('all');
|
||||
const pageSize = 8;
|
||||
const chartRef1 = useRef(null);
|
||||
const chartRef2 = useRef(null);
|
||||
const chartRef3 = useRef(null);
|
||||
const chart1 = useRef(null);
|
||||
const chart2 = useRef(null);
|
||||
const chart3 = useRef(null);
|
||||
|
||||
const validate = d => d && typeof d === 'object' && Array.isArray(d.data);
|
||||
|
||||
// 处理数据
|
||||
const processed = useMemo(() => {
|
||||
if (!data.data || data.data.length < 5) return null;
|
||||
const [depts = [], workers = [], trends = [], products = [], orders = []] = data.data;
|
||||
|
||||
const stats = {
|
||||
workers: depts.reduce((s, d) => s + parseInt(d.worker_count || 0), 0),
|
||||
output: depts.reduce((s, d) => s + (d.total_output_qty || 0), 0),
|
||||
hours: depts.reduce((s, d) => s + (d.total_work_hours || 0), 0),
|
||||
value: depts.reduce((s, d) => s + (d.estimated_output_value || 0), 0),
|
||||
efficiency: depts.length ? depts.reduce((s, d) => s + (d.efficiency_index || 0), 0) / depts.length : 0,
|
||||
performance: depts.length ? depts.reduce((s, d) => s + (d.performance_score || 0), 0) / depts.length : 0,
|
||||
defect: depts.length ? depts.reduce((s, d) => s + (d.defect_rate || 0), 0) / depts.length : 0,
|
||||
completion: depts.length ? depts.reduce((s, d) => s + (d.plan_completion_rate || 0), 0) / depts.length : 0
|
||||
};
|
||||
|
||||
const warnings = { GREEN: 0, YELLOW: 0, RED: 0 };
|
||||
depts.forEach(d => { const l = d.warning_level || 'GREEN'; if (warnings[l] !== undefined) warnings[l]++; });
|
||||
|
||||
const deptList = [...new Set(depts.map(d => d.department))];
|
||||
|
||||
return { depts, workers, trends, products, orders, stats, warnings, deptList };
|
||||
}, [data]);
|
||||
|
||||
// 筛选员工
|
||||
const filteredWorkers = useMemo(() => {
|
||||
if (!processed) return [];
|
||||
return deptFilter === 'all' ? processed.workers : processed.workers.filter(w => w.department === deptFilter);
|
||||
}, [processed, deptFilter]);
|
||||
|
||||
const workerPages = Math.ceil(filteredWorkers.length / pageSize);
|
||||
const pagedWorkers = filteredWorkers.slice((workerPage - 1) * pageSize, workerPage * pageSize);
|
||||
const productPages = processed ? Math.ceil(processed.products.length / pageSize) : 0;
|
||||
const pagedProducts = processed ? processed.products.slice((productPage - 1) * pageSize, productPage * pageSize) : [];
|
||||
|
||||
// 图表渲染
|
||||
useEffect(() => {
|
||||
if (!processed || !window.G2) return;
|
||||
[chart1, chart2, chart3].forEach(c => { if (c.current) { c.current.destroy(); c.current = null; } });
|
||||
|
||||
// 部门效率柱状图
|
||||
if (chartRef1.current && processed.depts.length > 0) {
|
||||
const d = processed.depts.map(x => ({ name: x.department.slice(0, 4), efficiency: x.efficiency_index || 0, performance: x.performance_score || 0 }));
|
||||
chart1.current = new G2.Chart({ container: chartRef1.current, autoFit: true, height: 180 });
|
||||
chart1.current.theme({ type: 'classicDark' });
|
||||
chart1.current.interval().data(d).encode('x', 'name').encode('y', 'efficiency').encode('color', 'name')
|
||||
.scale('color', { range: ['#6366f1', '#8b5cf6', '#a855f7', '#c084fc', '#d946ef', '#ec4899'] })
|
||||
.style('radius', 4).axis('x', { labelFontSize: 10, labelFill: '#94a3b8' }).axis('y', { title: false, labelFill: '#64748b' })
|
||||
.legend(false).tooltip({ items: [{ channel: 'y', name: '效率', valueFormatter: v => v.toFixed(1) }] });
|
||||
chart1.current.render();
|
||||
}
|
||||
|
||||
// 产值分布环形图
|
||||
if (chartRef2.current && processed.depts.length > 0) {
|
||||
const d = processed.depts.filter(x => x.estimated_output_value > 0).map(x => ({ name: x.department, value: x.estimated_output_value }));
|
||||
chart2.current = new G2.Chart({ container: chartRef2.current, autoFit: true, height: 180 });
|
||||
chart2.current.theme({ type: 'classicDark' });
|
||||
chart2.current.coordinate({ type: 'theta', innerRadius: 0.6 });
|
||||
chart2.current.interval().data(d).transform({ type: 'stackY' }).encode('y', 'value').encode('color', 'name')
|
||||
.scale('color', { range: ['#6366f1', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b'] })
|
||||
.style('stroke', '#1e293b').style('lineWidth', 2)
|
||||
.label({ text: d => d.name.slice(0, 3), position: 'outside', fontSize: 10, fill: '#94a3b8' })
|
||||
.legend(false).tooltip({ items: [{ channel: 'y', name: '产值', valueFormatter: v => '¥' + (v/10000).toFixed(1) + '万' }] });
|
||||
chart2.current.render();
|
||||
}
|
||||
|
||||
// 月度趋势折线图
|
||||
if (chartRef3.current && processed.trends.length > 0) {
|
||||
const d = processed.trends.map(x => ({ month: x.month, dept: x.department.slice(0, 3), output: x.total_output || 0 }));
|
||||
chart3.current = new G2.Chart({ container: chartRef3.current, autoFit: true, height: 180 });
|
||||
chart3.current.theme({ type: 'classicDark' });
|
||||
chart3.current.line().data(d).encode('x', 'month').encode('y', 'output').encode('color', 'dept')
|
||||
.scale('color', { range: ['#6366f1', '#8b5cf6', '#06b6d4', '#10b981'] })
|
||||
.style('lineWidth', 2).axis('x', { labelFontSize: 9, labelFill: '#64748b' }).axis('y', { title: false, labelFill: '#64748b' })
|
||||
.legend('color', { position: 'top', itemLabelFill: '#94a3b8', itemMarkerSize: 6 });
|
||||
chart3.current.point().data(d).encode('x', 'month').encode('y', 'output').encode('color', 'dept').style('r', 3);
|
||||
chart3.current.render();
|
||||
}
|
||||
|
||||
return () => { [chart1, chart2, chart3].forEach(c => { if (c.current) { c.current.destroy(); c.current = null; } }); };
|
||||
}, [processed]);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
const helper = new ChildPageHelper({
|
||||
autoRenderStatus: true, enableLog: true,
|
||||
onReady: async () => {
|
||||
await helper.autoInitialize(() => {
|
||||
const d = helper.parseTemplateData(lzwcaiComInitDate, defaultData);
|
||||
if (d && validate(d)) { setData(d); setError(null); }
|
||||
});
|
||||
}
|
||||
});
|
||||
helper.expose({
|
||||
setDataLzwcaiEmbedFrameFn: d => { if (validate(d)) { setData(d); setError(null); return { status: 'success' }; } return { status: 'error' }; },
|
||||
getDataLzwcaiEmbedFrameFn: () => ({ status: 'success', data }),
|
||||
captureScreenshotLzwcaiEmbedFrameFn: helper.createScreenshotMethod(),
|
||||
getRenderStatusLzwcaiEmbedFrameFn: () => helper.getRenderStatus()
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 分页组件
|
||||
const Pager = ({ page, total, onChange }) => total <= 1 ? null : html`
|
||||
<div class="pagination">
|
||||
<button class="page-btn" disabled=${page === 1} onClick=${() => onChange(page - 1)}>‹</button>
|
||||
${Array.from({ length: Math.min(5, total) }, (_, i) => {
|
||||
let p = i + 1;
|
||||
if (total > 5) { if (page <= 3) p = i + 1; else if (page >= total - 2) p = total - 4 + i; else p = page - 2 + i; }
|
||||
return html`<button key=${p} class="page-btn ${page === p ? 'active' : ''}" onClick=${() => onChange(p)}>${p}</button>`;
|
||||
})}
|
||||
<button class="page-btn" disabled=${page === total} onClick=${() => onChange(page + 1)}>›</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (error) return html`
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: '20px' }}>
|
||||
<div class="card" style=${{ padding: '40px', textAlign: 'center', maxWidth: '360px' }}>
|
||||
<div style=${{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div style=${{ fontSize: '18px', fontWeight: 600, color: '#f87171', marginBottom: '8px' }}>数据异常</div>
|
||||
<div style=${{ fontSize: '13px', color: '#64748b' }}>${error}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (!processed) return html`
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: '20px' }}>
|
||||
<div class="card animate-glow" style=${{ padding: '40px', textAlign: 'center' }}>
|
||||
<div class="animate-float" style=${{ fontSize: '48px', marginBottom: '16px' }}>📊</div>
|
||||
<div class="gradient-text" style=${{ fontSize: '18px', fontWeight: 600 }}>加载中...</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const { depts, stats, warnings, deptList, orders } = processed;
|
||||
|
||||
return html`
|
||||
<div style=${{ padding: '20px', maxWidth: '1600px', margin: '0 auto' }}>
|
||||
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="card animate-slide" style=${{ padding: '20px 28px', marginBottom: '20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div class="animate-glow" style=${{ width: '52px', height: '52px', borderRadius: '14px', background: 'linear-gradient(135deg, #6366f1, #a855f7)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '24px' }}>📊</div>
|
||||
<div>
|
||||
<h1 class="gradient-text" style=${{ fontSize: '22px', fontWeight: 700, margin: 0 }}>${config.title}</h1>
|
||||
<p style=${{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>人效分析 · 产值统计 · 损耗监控 · 实时数据</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style=${{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
${warnings.RED > 0 && html`<span class="tag tag-red animate-pulse"><span>●</span> ${warnings.RED} 预警</span>`}
|
||||
${warnings.YELLOW > 0 && html`<span class="tag tag-yellow"><span>●</span> ${warnings.YELLOW} 关注</span>`}
|
||||
<span class="tag tag-green"><span>●</span> ${warnings.GREEN} 正常</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 核心指标 -->
|
||||
<div style=${{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '16px', marginBottom: '20px' }}>
|
||||
${[
|
||||
{ v: stats.workers, l: '总人数', i: '👥', c: '#6366f1' },
|
||||
{ v: stats.output.toLocaleString(), l: '总产出', i: '📦', c: '#8b5cf6' },
|
||||
{ v: fmt(stats.hours, 1) + 'h', l: '总工时', i: '⏱️', c: '#06b6d4' },
|
||||
{ v: '¥' + (stats.value / 10000).toFixed(1) + '万', l: '总产值', i: '💰', c: '#10b981' },
|
||||
{ v: fmt(stats.efficiency, 1), l: '平均效率', i: '⚡', c: '#f59e0b' },
|
||||
{ v: fmt(stats.performance, 1), l: '平均绩效', i: '🎯', c: '#ec4899' },
|
||||
{ v: fmtPct(stats.completion), l: '完成率', i: '✅', c: '#14b8a6' },
|
||||
{ v: fmtPct(stats.defect), l: '不良率', i: '📉', c: stats.defect > 1 ? '#ef4444' : '#64748b', alert: stats.defect > 1 }
|
||||
].map((m, i) => html`
|
||||
<div key=${i} class="card animate-slide" style=${{ padding: '20px', animationDelay: i * 60 + 'ms', border: m.alert ? '1px solid rgba(239, 68, 68, 0.4)' : undefined }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<span style=${{ fontSize: '22px' }}>${m.i}</span>
|
||||
<div style=${{ width: '8px', height: '8px', borderRadius: '50%', background: m.c, boxShadow: '0 0 10px ' + m.c }}></div>
|
||||
</div>
|
||||
<div style=${{ fontSize: '24px', fontWeight: 700, color: m.alert ? '#f87171' : '#f1f5f9' }}>${m.v}</div>
|
||||
<div style=${{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>${m.l}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div style=${{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '20px' }}>
|
||||
<div class="card" style=${{ padding: '20px' }}>
|
||||
<div style=${{ fontSize: '14px', fontWeight: 600, color: '#e2e8f0', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style=${{ width: '6px', height: '6px', borderRadius: '50%', background: '#6366f1' }}></span>
|
||||
部门效率对比
|
||||
</div>
|
||||
${depts.length > 0 ? html`<div ref=${chartRef1}></div>` : html`<${Empty} icon="📊" text="暂无数据" />`}
|
||||
</div>
|
||||
<div class="card" style=${{ padding: '20px' }}>
|
||||
<div style=${{ fontSize: '14px', fontWeight: 600, color: '#e2e8f0', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style=${{ width: '6px', height: '6px', borderRadius: '50%', background: '#8b5cf6' }}></span>
|
||||
产值分布
|
||||
</div>
|
||||
${depts.length > 0 ? html`<div ref=${chartRef2}></div>` : html`<${Empty} icon="💰" text="暂无数据" />`}
|
||||
</div>
|
||||
<div class="card" style=${{ padding: '20px' }}>
|
||||
<div style=${{ fontSize: '14px', fontWeight: 600, color: '#e2e8f0', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style=${{ width: '6px', height: '6px', borderRadius: '50%', background: '#06b6d4' }}></span>
|
||||
月度趋势
|
||||
</div>
|
||||
${processed.trends.length > 0 ? html`<div ref=${chartRef3}></div>` : html`<${Empty} icon="📈" text="暂无数据" />`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 部门汇总表 -->
|
||||
<div class="card" style=${{ marginBottom: '20px', overflow: 'hidden' }}>
|
||||
<div style=${{ padding: '16px 20px', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style=${{ width: '6px', height: '6px', borderRadius: '50%', background: '#6366f1' }}></span>
|
||||
<span style=${{ fontSize: '14px', fontWeight: 600 }}>部门人效-产值-损耗汇总</span>
|
||||
<span class="tag tag-blue" style=${{ marginLeft: 'auto' }}>${depts.length} 部门</span>
|
||||
</div>
|
||||
${depts.length > 0 ? html`
|
||||
<div style=${{ overflowX: 'auto' }}>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>部门</th><th style=${{ textAlign: 'center' }}>人数</th><th style=${{ textAlign: 'center' }}>工时</th>
|
||||
<th style=${{ textAlign: 'center' }}>产出</th><th style=${{ textAlign: 'center' }}>人均</th><th style=${{ textAlign: 'center' }}>时均</th>
|
||||
<th style=${{ textAlign: 'center' }}>效率</th><th>完成率</th><th style=${{ textAlign: 'right' }}>产值</th>
|
||||
<th style=${{ textAlign: 'center' }}>不良率</th><th style=${{ textAlign: 'center' }}>绩效</th><th style=${{ textAlign: 'center' }}>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${depts.map((d, i) => {
|
||||
const w = warningCfg[d.warning_level] || warningCfg.GREEN;
|
||||
return html`
|
||||
<tr key=${i}>
|
||||
<td style=${{ fontWeight: 600, color: '#f1f5f9' }}>${d.department}</td>
|
||||
<td style=${{ textAlign: 'center' }}>${d.worker_count}</td>
|
||||
<td style=${{ textAlign: 'center' }}>${fmt(d.total_work_hours, 1)}h</td>
|
||||
<td style=${{ textAlign: 'center', fontWeight: 600, color: '#a5b4fc' }}>${d.total_output_qty?.toLocaleString() || 0}</td>
|
||||
<td style=${{ textAlign: 'center' }}>${fmt(d.output_per_worker, 1)}</td>
|
||||
<td style=${{ textAlign: 'center' }}>${fmt(d.output_per_hour, 1)}</td>
|
||||
<td style=${{ textAlign: 'center' }}>
|
||||
<span class="tag ${d.efficiency_index >= 80 ? 'tag-green' : d.efficiency_index >= 50 ? 'tag-yellow' : 'tag-red'}">${fmt(d.efficiency_index, 1)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div class="progress-bar" style=${{ flex: 1, maxWidth: '80px' }}>
|
||||
<div class="progress-fill" style=${{ width: Math.min(d.plan_completion_rate || 0, 100) + '%' }}></div>
|
||||
</div>
|
||||
<span style=${{ fontSize: '12px', color: '#94a3b8' }}>${fmtPct(d.plan_completion_rate)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style=${{ textAlign: 'right', fontWeight: 600, color: '#34d399' }}>${fmtMoney(d.estimated_output_value)}</td>
|
||||
<td style=${{ textAlign: 'center' }}>
|
||||
<span class="tag ${(d.defect_rate || 0) === 0 ? 'tag-green' : d.defect_rate < 1 ? 'tag-yellow' : 'tag-red'}">${fmtPct(d.defect_rate)}</span>
|
||||
</td>
|
||||
<td style=${{ textAlign: 'center', fontWeight: 600, color: '#a5b4fc' }}>${fmt(d.performance_score, 1)}</td>
|
||||
<td style=${{ textAlign: 'center' }}><span class="tag ${w.class}">${w.icon} ${w.label}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : html`<${Empty} icon="🏢" text="暂无部门数据" />`}
|
||||
</div>
|
||||
|
||||
<!-- 下方两栏:员工明细 + 产品质量 -->
|
||||
<div style=${{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
|
||||
<!-- 员工产出明细 -->
|
||||
<div class="card" style=${{ overflow: 'hidden' }}>
|
||||
<div style=${{ padding: '16px 20px', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '12px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style=${{ width: '6px', height: '6px', borderRadius: '50%', background: '#f59e0b' }}></span>
|
||||
<span style=${{ fontSize: '14px', fontWeight: 600 }}>员工产出明细</span>
|
||||
</div>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<select value=${deptFilter} onChange=${e => { setDeptFilter(e.target.value); setWorkerPage(1); }}
|
||||
style=${{ padding: '6px 12px', fontSize: '12px', borderRadius: '8px', border: '1px solid rgba(99, 102, 241, 0.3)', background: 'rgba(30, 41, 59, 0.8)', color: '#e2e8f0', cursor: 'pointer' }}>
|
||||
<option value="all">全部部门</option>
|
||||
${deptList.map(d => html`<option key=${d} value=${d}>${d}</option>`)}
|
||||
</select>
|
||||
<span class="tag tag-blue">${filteredWorkers.length} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
${filteredWorkers.length > 0 ? html`
|
||||
<div style=${{ overflowX: 'auto', maxHeight: '360px', overflowY: 'auto' }}>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>姓名</th><th>部门</th><th style=${{ textAlign: 'center' }}>工单</th><th style=${{ textAlign: 'center' }}>工时</th><th style=${{ textAlign: 'center' }}>产出</th><th style=${{ textAlign: 'center' }}>时均</th><th style=${{ textAlign: 'center' }}>排名</th></tr></thead>
|
||||
<tbody>
|
||||
${pagedWorkers.map((w, i) => html`
|
||||
<tr key=${i}>
|
||||
<td style=${{ fontWeight: 600, color: '#f1f5f9' }}>${w.worker_name}</td>
|
||||
<td><span class="tag tag-blue">${w.department}</span></td>
|
||||
<td style=${{ textAlign: 'center' }}>${w.work_order_count}</td>
|
||||
<td style=${{ textAlign: 'center' }}>${fmt(w.total_work_hours, 2)}h</td>
|
||||
<td style=${{ textAlign: 'center', fontWeight: 600, color: '#a5b4fc' }}>${w.total_output}</td>
|
||||
<td style=${{ textAlign: 'center' }}>
|
||||
<span class="tag ${w.output_per_hour >= 80 ? 'tag-green' : w.output_per_hour >= 40 ? 'tag-yellow' : 'tag-red'}">${fmt(w.output_per_hour, 1)}</span>
|
||||
</td>
|
||||
<td style=${{ textAlign: 'center' }}>
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '26px', height: '26px', borderRadius: '50%', fontSize: '11px', fontWeight: 700, background: parseInt(w.dept_output_rank) <= 3 ? 'linear-gradient(135deg, #f59e0b, #f97316)' : 'rgba(51, 65, 85, 0.5)', color: parseInt(w.dept_output_rank) <= 3 ? '#fff' : '#94a3b8' }}>${w.dept_output_rank}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style=${{ padding: '12px 20px', borderTop: '1px solid rgba(99, 102, 241, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style=${{ fontSize: '12px', color: '#64748b' }}>共 ${filteredWorkers.length} 人,第 ${workerPage}/${workerPages || 1} 页</span>
|
||||
<${Pager} page=${workerPage} total=${workerPages} onChange=${setWorkerPage} />
|
||||
</div>
|
||||
` : html`<${Empty} icon="👤" text="暂无员工数据" />`}
|
||||
</div>
|
||||
|
||||
<!-- 产品质量与损耗 -->
|
||||
<div class="card" style=${{ overflow: 'hidden' }}>
|
||||
<div style=${{ padding: '16px 20px', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style=${{ width: '6px', height: '6px', borderRadius: '50%', background: '#10b981' }}></span>
|
||||
<span style=${{ fontSize: '14px', fontWeight: 600 }}>产品质量与损耗</span>
|
||||
</div>
|
||||
<span class="tag tag-green">${processed.products.length} 产品</span>
|
||||
</div>
|
||||
${processed.products.length > 0 ? html`
|
||||
<div style=${{ overflowX: 'auto', maxHeight: '360px', overflowY: 'auto' }}>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>产品</th><th>编码</th><th>部门</th><th style=${{ textAlign: 'center' }}>合格</th><th style=${{ textAlign: 'center' }}>不良</th><th style=${{ textAlign: 'center' }}>不良率</th><th style=${{ textAlign: 'center' }}>损耗</th></tr></thead>
|
||||
<tbody>
|
||||
${pagedProducts.map((p, i) => {
|
||||
const lc = lossCfg[p.loss_level] || lossCfg.LOW;
|
||||
return html`
|
||||
<tr key=${i}>
|
||||
<td style=${{ fontWeight: 600, color: '#f1f5f9', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${p.product_name}</td>
|
||||
<td style=${{ fontFamily: 'monospace', fontSize: '11px', color: '#64748b' }}>${p.product_code}</td>
|
||||
<td><span class="tag tag-blue">${p.department}</span></td>
|
||||
<td style=${{ textAlign: 'center', color: '#34d399', fontWeight: 600 }}>${p.pass_qty?.toLocaleString() || 0}</td>
|
||||
<td style=${{ textAlign: 'center', color: (p.fail_qty || 0) > 0 ? '#f87171' : '#64748b' }}>${p.fail_qty || 0}</td>
|
||||
<td style=${{ textAlign: 'center' }}>
|
||||
<span class="tag ${p.defect_rate == null || p.defect_rate === 0 ? 'tag-green' : p.defect_rate < 1 ? 'tag-yellow' : 'tag-red'}">${p.defect_rate == null ? '-' : fmtPct(p.defect_rate)}</span>
|
||||
</td>
|
||||
<td style=${{ textAlign: 'center' }}><span class="tag ${lc.class}">${lc.label}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style=${{ padding: '12px 20px', borderTop: '1px solid rgba(99, 102, 241, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style=${{ fontSize: '12px', color: '#64748b' }}>共 ${processed.products.length} 产品,第 ${productPage}/${productPages || 1} 页</span>
|
||||
<${Pager} page=${productPage} total=${productPages} onChange=${setProductPage} />
|
||||
</div>
|
||||
` : html`<${Empty} icon="🔍" text="暂无产品数据" />`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单状态 -->
|
||||
${orders.length > 0 && html`
|
||||
<div class="card" style=${{ marginTop: '16px', padding: '20px' }}>
|
||||
<div style=${{ fontSize: '14px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style=${{ width: '6px', height: '6px', borderRadius: '50%', background: '#a855f7' }}></span>
|
||||
订单状态概览
|
||||
<span class="tag tag-blue" style=${{ marginLeft: 'auto' }}>${orders.length} 条</span>
|
||||
</div>
|
||||
<div style=${{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
${orders.map((o, i) => html`
|
||||
<div key=${i} style=${{ padding: '14px 16px', borderRadius: '10px', background: 'rgba(30, 41, 59, 0.5)', border: '1px solid rgba(99, 102, 241, 0.15)' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
|
||||
<span style=${{ fontSize: '13px', fontWeight: 600, color: '#e2e8f0' }}>${o.department}</span>
|
||||
<span class="tag ${o.status === 'CLOSED' ? 'tag-green' : o.status === 'STARTED' ? 'tag-blue' : 'tag-yellow'}">
|
||||
${o.status === 'CLOSED' ? '已完成' : o.status === 'STARTED' ? '进行中' : '待处理'}
|
||||
</span>
|
||||
</div>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '12px', color: '#94a3b8' }}>
|
||||
<span>${o.order_count} 单</span>
|
||||
<span style=${{ fontWeight: 600, color: '#a5b4fc' }}>${fmtPct(o.completion_rate)}</span>
|
||||
</div>
|
||||
<div class="progress-bar" style=${{ marginTop: '8px' }}>
|
||||
<div class="progress-fill" style=${{ width: Math.min(o.completion_rate || 0, 100) + '%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<!-- 底部 -->
|
||||
<div style=${{ textAlign: 'center', padding: '24px 0 8px', color: '#475569', fontSize: '12px' }}>
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '8px 20px', borderRadius: '20px', background: 'rgba(30, 41, 59, 0.5)', border: '1px solid rgba(99, 102, 241, 0.1)' }}>
|
||||
📊 人效-产值-损耗三维模型 · 实时监控
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
ReactDOM.render(React.createElement(App), document.getElementById('root'));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,791 @@
|
||||
<!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>
|
||||
567
lzwcai_mcpskills_mfg_data_agent/html/OnePageDecisionBrief.html
Normal file
567
lzwcai_mcpskills_mfg_data_agent/html/OnePageDecisionBrief.html
Normal file
@@ -0,0 +1,567 @@
|
||||
<!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; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif; min-height: 100vh; }
|
||||
|
||||
/* 动画 */
|
||||
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
|
||||
@keyframes glow { 0%, 100% { box-shadow: 0 0 20px rgba(99, 102, 241, 0.4); } 50% { box-shadow: 0 0 40px rgba(99, 102, 241, 0.6); } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
|
||||
@keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.anim-slide { animation: slideUp 0.5s ease-out forwards; opacity: 0; }
|
||||
.anim-float { animation: float 6s ease-in-out infinite; }
|
||||
.anim-glow { animation: glow 3s ease-in-out infinite; }
|
||||
.anim-pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
|
||||
/* 现代渐变背景 */
|
||||
.page-bg {
|
||||
background: linear-gradient(135deg, #0f0f23 0%, #1a1a3e 25%, #0d1b2a 50%, #1b263b 75%, #0f0f23 100%);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 20s ease infinite;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@keyframes gradientShift { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
||||
|
||||
.page-bg::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 80%, rgba(236, 72, 153, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 50% 50%, rgba(6, 182, 212, 0.08) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 玻璃卡片 */
|
||||
.glass {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 20px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.glass:hover {
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
.main-grid { display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 20px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 16px; }
|
||||
.footer-content { display: flex; align-items: center; gap: 32px; flex-wrap: wrap; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.main-grid { grid-template-columns: 1fr 1fr; }
|
||||
.metric-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.main-grid { grid-template-columns: 1fr; }
|
||||
.metric-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.metric-grid { grid-template-columns: 1fr; }
|
||||
.footer-content { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||||
.footer-content .divider-v { display: none; }
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="page-bg">
|
||||
<div id="root"></div>
|
||||
<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: 'g2', src: '/LzwcaiEmbedFrameFile/lib/g2@5.2.4.min.js', check: () => typeof window.G2 !== 'undefined' }
|
||||
]
|
||||
});
|
||||
await loader.loadAll();
|
||||
initApp();
|
||||
} catch (e) { console.error('加载失败:', e); }
|
||||
});
|
||||
|
||||
function initApp() {
|
||||
const { useState, useEffect, useMemo, useRef } = React;
|
||||
const html = htm.bind(React.createElement);
|
||||
const config = { title: '一页式决策简报' };
|
||||
const lzwcaiComInitDate = '{{lzwcaiComInitDate}}';
|
||||
const defaultData = { success: false, data: [[], [], [], [], [], []] };
|
||||
|
||||
// 健康状态配置
|
||||
const healthCfg = {
|
||||
HEALTHY: { label: '健康', gradient: 'linear-gradient(135deg, #10b981, #059669)', icon: '✓', glow: 'rgba(16, 185, 129, 0.4)' },
|
||||
WARNING: { label: '关注', gradient: 'linear-gradient(135deg, #f59e0b, #d97706)', icon: '!', glow: 'rgba(245, 158, 11, 0.4)' },
|
||||
CRITICAL: { label: '预警', gradient: 'linear-gradient(135deg, #ef4444, #dc2626)', icon: '✗', glow: 'rgba(239, 68, 68, 0.4)' }
|
||||
};
|
||||
|
||||
// 指标状态配置
|
||||
const metricCfg = {
|
||||
NORMAL: { color: '#10b981', bg: 'rgba(16, 185, 129, 0.15)', border: 'rgba(16, 185, 129, 0.3)' },
|
||||
EXCEEDED: { color: '#ef4444', bg: 'rgba(239, 68, 68, 0.15)', border: 'rgba(239, 68, 68, 0.3)' }
|
||||
};
|
||||
|
||||
// 格式化函数
|
||||
const fmt = (v, d = 0) => v == null || isNaN(v) ? '-' : Number(v).toLocaleString('zh-CN', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const fmtMoney = v => v == null || isNaN(v) ? '-' : '¥' + (Math.abs(v) >= 10000 ? (v / 10000).toFixed(1) + '万' : fmt(v));
|
||||
const fmtPct = v => v == null || isNaN(v) ? '-' : Number(v).toFixed(1) + '%';
|
||||
|
||||
// 空状态组件
|
||||
const Empty = ({ icon, text }) => html`
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 20px', color: 'rgba(255,255,255,0.4)' }}>
|
||||
<div style=${{ fontSize: '32px', marginBottom: '12px', opacity: 0.5 }}>${icon}</div>
|
||||
<div style=${{ fontSize: '13px' }}>${text}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 指标卡片组件
|
||||
const MetricCard = ({ icon, value, label, sub, gradient, delay = 0, alert = false, highlight = false }) => html`
|
||||
<div class="glass anim-slide" style=${{
|
||||
padding: '20px',
|
||||
animationDelay: delay + 'ms',
|
||||
background: alert ? 'linear-gradient(135deg, rgba(239,68,68,0.2), rgba(239,68,68,0.1))' :
|
||||
highlight ? 'linear-gradient(135deg, rgba(16,185,129,0.2), rgba(16,185,129,0.1))' : undefined,
|
||||
borderColor: alert ? 'rgba(239,68,68,0.3)' : highlight ? 'rgba(16,185,129,0.3)' : undefined
|
||||
}}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div style=${{
|
||||
width: '48px', height: '48px', borderRadius: '14px',
|
||||
background: gradient || 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '22px', boxShadow: '0 8px 20px rgba(99, 102, 241, 0.3)'
|
||||
}}>${icon}</div>
|
||||
${alert && html`<span class="anim-pulse" style=${{ width: '10px', height: '10px', borderRadius: '50%', background: '#ef4444', boxShadow: '0 0 12px rgba(239,68,68,0.6)' }}></span>`}
|
||||
</div>
|
||||
<div style=${{ fontSize: '28px', fontWeight: 700, color: alert ? '#f87171' : highlight ? '#34d399' : '#fff', marginBottom: '4px' }}>${value}</div>
|
||||
<div style=${{ fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>${label}</div>
|
||||
${sub && html`<div style=${{ fontSize: '11px', color: 'rgba(255,255,255,0.4)', marginTop: '4px' }}>${sub}</div>`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState(defaultData);
|
||||
const [error, setError] = useState(null);
|
||||
const chartRef1 = useRef(null);
|
||||
const chartRef2 = useRef(null);
|
||||
const chart1 = useRef(null);
|
||||
const chart2 = useRef(null);
|
||||
|
||||
const validate = d => d && typeof d === 'object' && Array.isArray(d.data);
|
||||
|
||||
// 处理数据
|
||||
const processed = useMemo(() => {
|
||||
if (!data.data || data.data.length < 6) return null;
|
||||
const [summary = [], comparison = [], customers = [], production = [], warnings = [], trends = []] = data.data;
|
||||
const s = summary[0] || {};
|
||||
return { summary: s, comparison, customers, production, warnings, trends };
|
||||
}, [data]);
|
||||
|
||||
// 图表渲染
|
||||
useEffect(() => {
|
||||
if (!processed || !window.G2) return;
|
||||
[chart1, chart2].forEach(c => { if (c.current) { c.current.destroy(); c.current = null; } });
|
||||
|
||||
// 客户贡献环形图
|
||||
if (chartRef1.current && processed.customers.length > 0) {
|
||||
const d = processed.customers.slice(0, 5).map(x => ({
|
||||
name: x.customer_name.length > 5 ? x.customer_name.slice(0, 5) + '..' : x.customer_name,
|
||||
value: x.total_order_amount || 0
|
||||
}));
|
||||
chart1.current = new G2.Chart({ container: chartRef1.current, autoFit: true, height: 200 });
|
||||
chart1.current.theme({ type: 'classicDark' });
|
||||
chart1.current.coordinate({ type: 'theta', innerRadius: 0.65 });
|
||||
chart1.current.interval().data(d).transform({ type: 'stackY' }).encode('y', 'value').encode('color', 'name')
|
||||
.scale('color', { range: ['#818cf8', '#a78bfa', '#c084fc', '#e879f9', '#f472b6'] })
|
||||
.style('stroke', 'rgba(15,15,35,0.8)').style('lineWidth', 3)
|
||||
.label({ text: d => d.name, position: 'outside', fontSize: 10, fill: 'rgba(255,255,255,0.7)', connector: true })
|
||||
.legend(false).tooltip({ items: [{ channel: 'y', name: '金额', valueFormatter: v => '¥' + (v/10000).toFixed(1) + '万' }] });
|
||||
chart1.current.render();
|
||||
}
|
||||
|
||||
// 月度趋势面积图
|
||||
if (chartRef2.current && processed.trends.length > 0) {
|
||||
const d = processed.trends.slice().reverse().map(x => ({ month: x.month?.slice(5) || '', amount: (x.sales_amount || 0) / 10000 }));
|
||||
chart2.current = new G2.Chart({ container: chartRef2.current, autoFit: true, height: 180 });
|
||||
chart2.current.theme({ type: 'classicDark' });
|
||||
chart2.current.area().data(d).encode('x', 'month').encode('y', 'amount')
|
||||
.style('fill', 'linear-gradient(90deg, rgba(99,102,241,0.6), rgba(168,85,247,0.6))')
|
||||
.style('fillOpacity', 0.4)
|
||||
.axis('x', { labelFontSize: 10, labelFill: 'rgba(255,255,255,0.5)', line: null, tick: null })
|
||||
.axis('y', { title: false, labelFill: 'rgba(255,255,255,0.4)', labelFormatter: v => v + '万', grid: { stroke: 'rgba(255,255,255,0.05)' } });
|
||||
chart2.current.line().data(d).encode('x', 'month').encode('y', 'amount')
|
||||
.style('stroke', 'url(#lineGradient)').style('lineWidth', 3);
|
||||
chart2.current.point().data(d).encode('x', 'month').encode('y', 'amount')
|
||||
.style('fill', '#a78bfa').style('r', 5).style('stroke', '#fff').style('lineWidth', 2);
|
||||
chart2.current.render();
|
||||
}
|
||||
|
||||
return () => { [chart1, chart2].forEach(c => { if (c.current) { c.current.destroy(); c.current = null; } }); };
|
||||
}, [processed]);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
const helper = new ChildPageHelper({
|
||||
autoRenderStatus: true, enableLog: true,
|
||||
onReady: async () => {
|
||||
await helper.autoInitialize(() => {
|
||||
const d = helper.parseTemplateData(lzwcaiComInitDate, defaultData);
|
||||
if (d && validate(d)) { setData(d); setError(null); }
|
||||
});
|
||||
}
|
||||
});
|
||||
helper.expose({
|
||||
setDataLzwcaiEmbedFrameFn: d => { if (validate(d)) { setData(d); setError(null); return { status: 'success' }; } return { status: 'error' }; },
|
||||
getDataLzwcaiEmbedFrameFn: () => ({ status: 'success', data }),
|
||||
captureScreenshotLzwcaiEmbedFrameFn: helper.createScreenshotMethod(),
|
||||
getRenderStatusLzwcaiEmbedFrameFn: () => helper.getRenderStatus()
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 错误状态
|
||||
if (error) return html`
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: '20px' }}>
|
||||
<div class="glass" style=${{ padding: '48px', textAlign: 'center', maxWidth: '400px' }}>
|
||||
<div class="anim-float" style=${{ fontSize: '56px', marginBottom: '20px' }}>⚠️</div>
|
||||
<div style=${{ fontSize: '20px', fontWeight: 700, color: '#f87171', marginBottom: '12px' }}>数据加载异常</div>
|
||||
<div style=${{ fontSize: '14px', color: 'rgba(255,255,255,0.5)' }}>${error}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 加载状态
|
||||
if (!processed) return html`
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: '20px' }}>
|
||||
<div class="glass anim-glow" style=${{ padding: '56px', textAlign: 'center' }}>
|
||||
<div class="anim-float" style=${{ fontSize: '56px', marginBottom: '20px' }}>📊</div>
|
||||
<div style=${{ fontSize: '20px', fontWeight: 700, background: 'linear-gradient(135deg, #818cf8, #c084fc)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>加载决策数据...</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const { summary: s, comparison, customers, production, warnings, trends } = processed;
|
||||
const health = healthCfg[s.health_status] || healthCfg.WARNING;
|
||||
|
||||
return html`
|
||||
<div style=${{ padding: '24px', maxWidth: '1500px', margin: '0 auto', position: 'relative', zIndex: 1 }}>
|
||||
|
||||
<!-- 顶部标题栏 -->
|
||||
<header class="glass anim-slide" style=${{ padding: '20px 28px', marginBottom: '24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div class="anim-glow" style=${{
|
||||
width: '56px', height: '56px', borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, #6366f1, #a855f7, #ec4899)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '26px'
|
||||
}}>📊</div>
|
||||
<div>
|
||||
<h1 style=${{ fontSize: '24px', fontWeight: 700, color: '#fff', margin: 0, background: 'linear-gradient(135deg, #fff, rgba(255,255,255,0.8))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>${config.title}</h1>
|
||||
<p style=${{ fontSize: '12px', color: 'rgba(255,255,255,0.5)', marginTop: '4px' }}>📅 ${s.report_date || '-'} · 订单 · 生产 · 财务 · 售后</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style=${{
|
||||
padding: '10px 20px', borderRadius: '12px',
|
||||
background: health.gradient,
|
||||
boxShadow: '0 4px 20px ' + health.glow,
|
||||
display: 'flex', alignItems: 'center', gap: '8px'
|
||||
}}>
|
||||
<span style=${{ fontSize: '16px', fontWeight: 700 }}>${health.icon}</span>
|
||||
<span style=${{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>经营${health.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 核心指标网格 -->
|
||||
<div class="metric-grid" style=${{ marginBottom: '24px' }}>
|
||||
<${MetricCard} icon="📋" value=${s.sales_order_count || 0} label="销售订单" sub=${fmtMoney(s.total_sales_amount)} gradient="linear-gradient(135deg, #6366f1, #8b5cf6)" delay=${0} />
|
||||
<${MetricCard} icon="🏭" value=${s.work_order_count || 0} label="生产工单" sub=${'完成 ' + (s.completed_work_orders || 0) + ' / 进行 ' + (s.in_progress_work_orders || 0)} gradient="linear-gradient(135deg, #3b82f6, #06b6d4)" delay=${50} />
|
||||
<${MetricCard} icon="📊" value=${fmtPct(s.production_completion_rate)} label="生产完成率" sub=${(s.completed_qty || 0) + ' / ' + (s.planned_qty || 0)} gradient="linear-gradient(135deg, #10b981, #14b8a6)" delay=${100} highlight=${s.production_completion_rate >= 90} />
|
||||
<${MetricCard} icon="✅" value=${fmtPct(s.pass_rate)} label="质检合格率" sub=${(s.pass_qty || 0) + ' 件合格'} gradient="linear-gradient(135deg, #22c55e, #10b981)" delay=${150} highlight=${s.pass_rate >= 98} />
|
||||
<${MetricCard} icon="💰" value=${fmtMoney(s.net_cash_flow)} label="净现金流" sub=${'收入 ' + fmtMoney(s.total_ar_amount)} gradient="linear-gradient(135deg, #f59e0b, #f97316)" delay=${200} highlight=${s.net_cash_flow > 0} />
|
||||
<${MetricCard} icon="↩️" value=${fmtPct(s.return_rate)} label="退货率" sub=${fmtMoney(s.total_return_amount)} gradient="linear-gradient(135deg, #ef4444, #f97316)" delay=${250} alert=${s.return_rate > 5} />
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-grid" style=${{ marginBottom: '24px' }}>
|
||||
|
||||
<!-- 左栏 -->
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
|
||||
<!-- 订单概况 -->
|
||||
<div class="glass anim-slide" style=${{ padding: '20px', animationDelay: '300ms' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
<div style=${{ width: '32px', height: '32px', borderRadius: '10px', background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px' }}>📋</div>
|
||||
<span style=${{ fontSize: '15px', fontWeight: 600, color: '#fff' }}>订单概况</span>
|
||||
</div>
|
||||
<div style=${{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
${[
|
||||
{ l: '订单总数', v: s.sales_order_count || 0, c: '#818cf8' },
|
||||
{ l: '订单均价', v: fmtMoney(s.avg_order_amount), c: '#a78bfa' },
|
||||
{ l: '已付款', v: s.paid_order_count || 0, c: '#34d399' },
|
||||
{ l: '部分付款', v: s.partial_paid_count || 0, c: '#fbbf24' },
|
||||
{ l: '未付款', v: s.unpaid_order_count || 0, c: '#f87171' },
|
||||
{ l: '应收款', v: fmtMoney(s.receivable_amount), c: '#fb923c' }
|
||||
].map((item, i) => html`
|
||||
<div key=${i} style=${{ padding: '12px', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', borderLeft: '3px solid ' + item.c }}>
|
||||
<div style=${{ fontSize: '11px', color: 'rgba(255,255,255,0.5)', marginBottom: '4px' }}>${item.l}</div>
|
||||
<div style=${{ fontSize: '16px', fontWeight: 700, color: item.c }}>${item.v}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 财务流水 -->
|
||||
<div class="glass anim-slide" style=${{ padding: '20px', animationDelay: '350ms' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
<div style=${{ width: '32px', height: '32px', borderRadius: '10px', background: 'linear-gradient(135deg, #f59e0b, #f97316)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px' }}>💰</div>
|
||||
<span style=${{ fontSize: '15px', fontWeight: 600, color: '#fff' }}>财务流水</span>
|
||||
</div>
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
${[
|
||||
{ l: '应收入账', v: fmtMoney(s.total_ar_amount), n: s.ar_receipt_count, c: '#34d399', icon: '📥' },
|
||||
{ l: '应付支出', v: fmtMoney(s.total_ap_amount), n: s.ap_payment_count, c: '#f87171', icon: '📤' },
|
||||
{ l: '开票金额', v: fmtMoney(s.total_invoice_amount), n: s.invoice_count, c: '#60a5fa', icon: '🧾' },
|
||||
{ l: '发货金额', v: fmtMoney(s.total_shipment_amount), n: s.shipment_count, c: '#a78bfa', icon: '🚚' }
|
||||
].map((item, i) => html`
|
||||
<div key=${i} style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 12px', background: 'rgba(255,255,255,0.05)', borderRadius: '10px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>${item.icon}</span>
|
||||
<span style=${{ fontSize: '12px', color: 'rgba(255,255,255,0.7)' }}>${item.l}</span>
|
||||
</div>
|
||||
<div style=${{ textAlign: 'right' }}>
|
||||
<span style=${{ fontSize: '14px', fontWeight: 700, color: item.c }}>${item.v}</span>
|
||||
<span style=${{ fontSize: '10px', color: 'rgba(255,255,255,0.4)', marginLeft: '6px' }}>${item.n}笔</span>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中栏 -->
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
|
||||
<!-- 客户贡献 -->
|
||||
<div class="glass anim-slide" style=${{ padding: '20px', animationDelay: '400ms' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style=${{ width: '32px', height: '32px', borderRadius: '10px', background: 'linear-gradient(135deg, #ec4899, #f472b6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px' }}>👥</div>
|
||||
<span style=${{ fontSize: '15px', fontWeight: 600, color: '#fff' }}>客户贡献 TOP5</span>
|
||||
</div>
|
||||
<span style=${{ padding: '4px 10px', background: 'rgba(236,72,153,0.2)', borderRadius: '20px', fontSize: '11px', color: '#f472b6' }}>${customers.length} 客户</span>
|
||||
</div>
|
||||
${customers.length > 0 ? html`
|
||||
<div style=${{ display: 'flex', gap: '20px' }}>
|
||||
<div ref=${chartRef1} style=${{ flex: '0 0 200px' }}></div>
|
||||
<div style=${{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
${customers.slice(0, 5).map((c, i) => html`
|
||||
<div key=${i} style=${{
|
||||
display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 12px',
|
||||
background: i === 0 ? 'linear-gradient(135deg, rgba(129,140,248,0.2), rgba(167,139,250,0.1))' : 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '10px', border: i === 0 ? '1px solid rgba(129,140,248,0.3)' : '1px solid transparent'
|
||||
}}>
|
||||
<span style=${{
|
||||
width: '24px', height: '24px', borderRadius: '8px',
|
||||
background: i === 0 ? 'linear-gradient(135deg, #818cf8, #a78bfa)' : 'rgba(255,255,255,0.1)',
|
||||
color: '#fff', fontSize: '11px', fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>${i + 1}</span>
|
||||
<div style=${{ flex: 1, minWidth: 0 }}>
|
||||
<div style=${{ fontSize: '13px', fontWeight: 600, color: '#fff', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${c.customer_name}</div>
|
||||
<div style=${{ fontSize: '10px', color: 'rgba(255,255,255,0.4)' }}>${c.order_count}单 · 贡献${fmtPct(c.contribution_rate)}</div>
|
||||
</div>
|
||||
<span style=${{ fontSize: '14px', fontWeight: 700, color: '#34d399' }}>${fmtMoney(c.total_order_amount)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : html`<${Empty} icon="👥" text="暂无客户数据" />`}
|
||||
</div>
|
||||
|
||||
<!-- 销售趋势 -->
|
||||
<div class="glass anim-slide" style=${{ padding: '20px', animationDelay: '450ms' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style=${{ width: '32px', height: '32px', borderRadius: '10px', background: 'linear-gradient(135deg, #06b6d4, #0ea5e9)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px' }}>📈</div>
|
||||
<span style=${{ fontSize: '15px', fontWeight: 600, color: '#fff' }}>销售趋势</span>
|
||||
</div>
|
||||
</div>
|
||||
${trends.length > 0 ? html`
|
||||
<div ref=${chartRef2} style=${{ marginBottom: '16px' }}></div>
|
||||
<div style=${{ display: 'flex', justifyContent: 'space-around', padding: '12px 0', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
${trends.slice(0, 4).map((t, i) => html`
|
||||
<div key=${i} style=${{ textAlign: 'center' }}>
|
||||
<div style=${{ fontSize: '11px', color: 'rgba(255,255,255,0.4)', marginBottom: '4px' }}>${t.month}</div>
|
||||
<div style=${{ fontSize: '16px', fontWeight: 700, color: '#fff' }}>${t.order_count}单</div>
|
||||
<div style=${{ fontSize: '12px', color: '#34d399' }}>${fmtMoney(t.sales_amount)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : html`<${Empty} icon="📈" text="暂无趋势数据" />`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏 -->
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
|
||||
<!-- 生产进度 -->
|
||||
<div class="glass anim-slide" style=${{ padding: '20px', animationDelay: '500ms' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style=${{ width: '32px', height: '32px', borderRadius: '10px', background: 'linear-gradient(135deg, #10b981, #14b8a6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px' }}>🏭</div>
|
||||
<span style=${{ fontSize: '15px', fontWeight: 600, color: '#fff' }}>生产进度</span>
|
||||
</div>
|
||||
</div>
|
||||
${production.filter(p => p.work_order_count > 0).length > 0 ? html`
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', gap: '10px', maxHeight: '220px', overflowY: 'auto' }}>
|
||||
${production.filter(p => p.work_order_count > 0).map((p, i) => {
|
||||
const rate = p.completion_rate || 0;
|
||||
const color = rate >= 100 ? '#34d399' : rate >= 70 ? '#fbbf24' : '#f87171';
|
||||
return html`
|
||||
<div key=${i} style=${{ padding: '12px', background: 'rgba(255,255,255,0.05)', borderRadius: '10px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style=${{ fontSize: '13px', fontWeight: 600, color: '#fff' }}>${p.product_category}</span>
|
||||
<span style=${{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>${p.work_order_count}单</span>
|
||||
</div>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style=${{ flex: 1, height: '8px', background: 'rgba(255,255,255,0.1)', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div style=${{ width: Math.min(rate, 100) + '%', height: '100%', background: 'linear-gradient(90deg, ' + color + ', ' + color + '99)', borderRadius: '4px', transition: 'width 0.6s ease' }}></div>
|
||||
</div>
|
||||
<span style=${{ fontSize: '13px', fontWeight: 700, color: color, minWidth: '50px', textAlign: 'right' }}>${fmtPct(rate)}</span>
|
||||
</div>
|
||||
<div style=${{ fontSize: '10px', color: 'rgba(255,255,255,0.4)', marginTop: '6px' }}>
|
||||
完成 ${p.completed_qty || 0} / 计划 ${p.planned_qty || 0} · ${p.worker_count || 0}人 · ${p.total_work_hours || 0}h
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : html`<${Empty} icon="🏭" text="暂无生产数据" />`}
|
||||
</div>
|
||||
|
||||
<!-- 预警指标 -->
|
||||
<div class="glass anim-slide" style=${{ padding: '20px', animationDelay: '550ms' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style=${{ width: '32px', height: '32px', borderRadius: '10px', background: 'linear-gradient(135deg, #ef4444, #f97316)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px' }}>⚠️</div>
|
||||
<span style=${{ fontSize: '15px', fontWeight: 600, color: '#fff' }}>预警指标</span>
|
||||
</div>
|
||||
${warnings.filter(w => w.status === 'EXCEEDED').length > 0 && html`
|
||||
<span class="anim-pulse" style=${{ padding: '4px 10px', background: 'rgba(239,68,68,0.2)', borderRadius: '20px', fontSize: '11px', color: '#f87171' }}>
|
||||
${warnings.filter(w => w.status === 'EXCEEDED').length} 项超标
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
${warnings.length > 0 ? html`
|
||||
<div style=${{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
${warnings.map((w, i) => {
|
||||
const cfg = metricCfg[w.status] || metricCfg.NORMAL;
|
||||
const names = {
|
||||
'unpaid_order_amount': '未付款金额',
|
||||
'defect_rate_pct': '不良率',
|
||||
'production_completion_rate': '生产完成率',
|
||||
'return_rate_pct': '退货率'
|
||||
};
|
||||
return html`
|
||||
<div key=${i} style=${{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px', background: cfg.bg, borderRadius: '10px',
|
||||
border: '1px solid ' + cfg.border
|
||||
}}>
|
||||
<div>
|
||||
<div style=${{ fontSize: '13px', fontWeight: 600, color: '#fff' }}>${names[w.metric_name] || w.metric_name}</div>
|
||||
<div style=${{ fontSize: '10px', color: 'rgba(255,255,255,0.5)', marginTop: '2px' }}>阈值: ${w.threshold}</div>
|
||||
</div>
|
||||
<div style=${{ textAlign: 'right' }}>
|
||||
<div style=${{ fontSize: '18px', fontWeight: 700, color: cfg.color }}>${w.current_value}</div>
|
||||
<div style=${{ fontSize: '10px', color: cfg.color, fontWeight: 600 }}>${w.status === 'EXCEEDED' ? '超标' : '正常'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : html`<${Empty} icon="✅" text="暂无预警" />`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部汇总栏 -->
|
||||
<footer class="glass anim-slide" style=${{ padding: '20px 28px', animationDelay: '600ms', background: 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(168,85,247,0.1))' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '20px' }}>
|
||||
<div class="footer-content">
|
||||
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style=${{ width: '40px', height: '40px', borderRadius: '12px', background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', flexShrink: 0 }}>👥</div>
|
||||
<div>
|
||||
<div style=${{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>人效统计</div>
|
||||
<div style=${{ fontSize: '14px', color: '#fff' }}>
|
||||
<span style=${{ fontWeight: 700, color: '#818cf8' }}>${s.active_worker_count || 0}</span> 人 ·
|
||||
<span style=${{ fontWeight: 700 }}>${fmt(s.total_work_hours, 1)}</span> 工时 ·
|
||||
人均 <span style=${{ fontWeight: 700, color: '#34d399' }}>${fmt(s.output_per_worker, 1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider-v" style=${{ width: '1px', height: '36px', background: 'rgba(255,255,255,0.1)' }}></div>
|
||||
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style=${{ width: '40px', height: '40px', borderRadius: '12px', background: 'linear-gradient(135deg, #10b981, #14b8a6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', flexShrink: 0 }}>✅</div>
|
||||
<div>
|
||||
<div style=${{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>质检统计</div>
|
||||
<div style=${{ fontSize: '14px', color: '#fff' }}>
|
||||
<span style=${{ fontWeight: 700 }}>${s.qc_batch_count || 0}</span> 批次 ·
|
||||
合格 <span style=${{ fontWeight: 700, color: '#34d399' }}>${s.pass_qty || 0}</span> ·
|
||||
不良 <span style=${{ fontWeight: 700, color: s.fail_qty > 0 ? '#f87171' : 'rgba(255,255,255,0.5)' }}>${s.fail_qty || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider-v" style=${{ width: '1px', height: '36px', background: 'rgba(255,255,255,0.1)' }}></div>
|
||||
|
||||
<div style=${{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style=${{ width: '40px', height: '40px', borderRadius: '12px', background: 'linear-gradient(135deg, #f59e0b, #f97316)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', flexShrink: 0 }}>📦</div>
|
||||
<div>
|
||||
<div style=${{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>采购与退货</div>
|
||||
<div style=${{ fontSize: '14px', color: '#fff' }}>
|
||||
采购 <span style=${{ fontWeight: 700 }}>${s.purchase_order_count || 0}</span> 单 ·
|
||||
退货 <span style=${{ fontWeight: 700, color: s.return_count > 0 ? '#f87171' : 'rgba(255,255,255,0.5)' }}>${s.return_count || 0}</span> 单
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style=${{ textAlign: 'right' }}>
|
||||
<div style=${{ fontSize: '10px', color: 'rgba(255,255,255,0.4)' }}>报告生成时间</div>
|
||||
<div style=${{ fontSize: '13px', color: '#a78bfa', fontWeight: 500 }}>${new Date().toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
ReactDOM.render(html`<${App} />`, document.getElementById('root'));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,626 @@
|
||||
<!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>
|
||||
680
lzwcai_mcpskills_mfg_data_agent/html/SupplyChainRiskWarning.html
Normal file
680
lzwcai_mcpskills_mfg_data_agent/html/SupplyChainRiskWarning.html
Normal file
@@ -0,0 +1,680 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>供应链风险预警</title>
|
||||
|
||||
<script src="/LzwcaiEmbedFrameFile/LzwcaiEmbedFrameV5.js"></script>
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-2px); } 75% { transform: translateX(2px); } }
|
||||
.animate-slide-in { animation: slideIn 0.5s ease-out forwards; }
|
||||
.animate-fade-in { animation: fadeIn 0.4s ease-out forwards; }
|
||||
.animate-scale-in { animation: scaleIn 0.4s ease-out forwards; }
|
||||
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
.animate-blink { animation: blink 1.5s ease-in-out infinite; }
|
||||
.animate-shake { animation: shake 0.5s ease-in-out infinite; }
|
||||
.glass { backdrop-filter: blur(16px); background: rgba(255,255,255,0.85); }
|
||||
.gradient-text { background: linear-gradient(135deg, #ef4444 0%, #f97316 50%, #eab308 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.page-bg { background: linear-gradient(135deg, #fef2f2 0%, #fff7ed 30%, #fefce8 70%, #f8fafc 100%); min-height: 100vh; }
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 50; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.2s ease-out; }
|
||||
.modal-content { background: white; border-radius: 1.5rem; max-width: 90vw; max-height: 85vh; overflow: hidden; animation: scaleIn 0.3s ease-out; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); }
|
||||
.clickable-row { cursor: pointer; transition: all 0.2s; }
|
||||
.clickable-row:hover { background: #f8fafc !important; transform: translateX(2px); }
|
||||
.clickable-card { cursor: pointer; transition: all 0.2s; }
|
||||
.clickable-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px -5px rgba(0,0,0,0.1); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', async function() {
|
||||
try {
|
||||
const loader = new LibraryLoader({
|
||||
enableLog: true, async: false,
|
||||
libraries: [
|
||||
{ name: 'react', src: '/LzwcaiEmbedFrameFile/lib/react.production.min.js', check: () => typeof window.React !== 'undefined' },
|
||||
{ name: 'reactDOM', src: '/LzwcaiEmbedFrameFile/lib/react-dom.production.min.js', check: () => typeof window.ReactDOM !== 'undefined' },
|
||||
{ name: 'htm', src: '/LzwcaiEmbedFrameFile/lib/htm.js', check: () => typeof window.htm !== 'undefined' },
|
||||
{ name: 'tailwindcss', src: '/LzwcaiEmbedFrameFile/lib/tailwindcss-3.4.17.js', check: () => typeof window.tailwind !== 'undefined' },
|
||||
{ name: 'g2', src: '/LzwcaiEmbedFrameFile/lib/g2@5.2.4.min.js', check: () => typeof window.G2 !== 'undefined' }
|
||||
]
|
||||
});
|
||||
await loader.loadAll();
|
||||
initReactApp();
|
||||
} catch (error) { console.error('[SupplyChainRiskWarning] 库加载失败:', error); }
|
||||
});
|
||||
|
||||
function initReactApp() {
|
||||
const { useState, useEffect, useMemo, useRef } = React;
|
||||
const html = htm.bind(React.createElement);
|
||||
|
||||
const config = { enableLog: true, title: '供应链风险预警' };
|
||||
const lzwcaiComInitDate = '{{lzwcaiComInitDate}}';
|
||||
const defaultData = { success: false, data: [[], [], [], [], [], []] };
|
||||
|
||||
// 风险等级配置
|
||||
const riskLevelConfig = {
|
||||
'HIGH': { label: '高风险', bg: 'from-red-500 to-rose-600', light: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', icon: '🔴', dot: 'bg-red-500' },
|
||||
'MEDIUM': { label: '中风险', bg: 'from-amber-400 to-orange-500', light: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200', icon: '🟡', dot: 'bg-amber-500' },
|
||||
'LOW': { label: '低风险', bg: 'from-emerald-400 to-teal-500', light: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200', icon: '🟢', dot: 'bg-emerald-500' }
|
||||
};
|
||||
|
||||
// 风险模式配置
|
||||
const riskPatternConfig = {
|
||||
'DELIVERY_AND_QUALITY': { label: '交期+质量双风险', icon: '⚠️', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
'DELIVERY_ISSUE': { label: '交期异常', icon: '⏰', color: 'text-orange-600', bg: 'bg-orange-50' },
|
||||
'QUALITY_ISSUE': { label: '质量问题', icon: '🔍', color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
'LOGISTICS_STALL': { label: '物流停滞', icon: '🚚', color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
'NORMAL': { label: '正常', icon: '✅', color: 'text-emerald-600', bg: 'bg-emerald-50' }
|
||||
};
|
||||
|
||||
// 行动要求配置
|
||||
const actionConfig = {
|
||||
'IMMEDIATE_FOLLOWUP': { label: '立即跟进', icon: '🚨', color: 'text-red-600', bg: 'bg-red-100' },
|
||||
'MONITOR': { label: '持续监控', icon: '👁️', color: 'text-amber-600', bg: 'bg-amber-100' },
|
||||
'NORMAL': { label: '正常', icon: '✓', color: 'text-emerald-600', bg: 'bg-emerald-100' }
|
||||
};
|
||||
|
||||
// 风险评估配置
|
||||
const assessmentConfig = {
|
||||
'HIGH_RISK': { label: '高风险', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
'MEDIUM_RISK': { label: '中风险', color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
'EXCELLENT': { label: '优秀', color: 'text-emerald-600', bg: 'bg-emerald-50' }
|
||||
};
|
||||
|
||||
// 空状态组件
|
||||
const EmptyState = ({ icon, title, desc }) => html`
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-slate-100 to-slate-200 rounded-2xl flex items-center justify-center text-2xl mb-3">${icon}</div>
|
||||
<p class="text-sm font-medium text-slate-500">${title}</p>
|
||||
<p class="text-xs text-slate-400 mt-1">${desc}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 弹窗组件
|
||||
const Modal = ({ isOpen, onClose, title, icon, children }) => {
|
||||
if (!isOpen) return null;
|
||||
return html`
|
||||
<div class="modal-overlay" onClick=${onClose}>
|
||||
<div class="modal-content w-full max-w-2xl" onClick=${e => e.stopPropagation()}>
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<span class="text-xl">${icon}</span>
|
||||
${title}
|
||||
</h3>
|
||||
<button onClick=${onClose} class="w-8 h-8 rounded-full bg-slate-100 hover:bg-slate-200 flex items-center justify-center text-slate-500 hover:text-slate-700 transition-colors">✕</button>
|
||||
</div>
|
||||
<div class="p-6 max-h-[60vh] overflow-y-auto scrollbar-thin">
|
||||
${children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
function ChartApp() {
|
||||
const [rawData, setRawData] = useState(defaultData);
|
||||
const [pageHelper, setPageHelper] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [supplierPage, setSupplierPage] = useState(1);
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [filterRisk, setFilterRisk] = useState('all');
|
||||
const [filterPattern, setFilterPattern] = useState('all');
|
||||
// 弹窗状态
|
||||
const [modalData, setModalData] = useState(null);
|
||||
const [modalType, setModalType] = useState(null);
|
||||
const pageSize = 10;
|
||||
const chartRef1 = useRef(null);
|
||||
const chartRef3 = useRef(null);
|
||||
const chartInstance1 = useRef(null);
|
||||
const chartInstance3 = useRef(null);
|
||||
|
||||
const validateData = (data) => {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: '数据格式错误' };
|
||||
if (!Array.isArray(data.data)) return { valid: false, error: 'data 字段应为数组' };
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openModal = (type, data) => {
|
||||
setModalType(type);
|
||||
setModalData(data);
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
const closeModal = () => {
|
||||
setModalType(null);
|
||||
setModalData(null);
|
||||
};
|
||||
|
||||
// 处理数据
|
||||
const processedData = useMemo(() => {
|
||||
if (!rawData.data || rawData.data.length < 6) return null;
|
||||
|
||||
const [suppliers = [], highRiskOrders = [], categoryRisk = [], monthlyTrend = [], metrics = [], riskRanking = []] = rawData.data;
|
||||
|
||||
const metricsMap = {};
|
||||
metrics.forEach(m => { metricsMap[m.metric_name] = m; });
|
||||
|
||||
const riskDistribution = { HIGH: 0, MEDIUM: 0, LOW: 0 };
|
||||
suppliers.forEach(s => {
|
||||
if (riskDistribution[s.risk_level] !== undefined) riskDistribution[s.risk_level]++;
|
||||
});
|
||||
|
||||
const patternStats = {};
|
||||
suppliers.forEach(s => {
|
||||
const pattern = s.risk_pattern || 'NORMAL';
|
||||
patternStats[pattern] = (patternStats[pattern] || 0) + 1;
|
||||
});
|
||||
|
||||
const categoryStats = categoryRisk.map(c => ({
|
||||
...c,
|
||||
supplier_count: parseInt(c.supplier_count || 0),
|
||||
order_count: parseInt(c.order_count || 0),
|
||||
return_count: parseInt(c.return_count || 0)
|
||||
}));
|
||||
|
||||
return { suppliers, highRiskOrders, categoryRisk: categoryStats, monthlyTrend, metrics: metricsMap, riskRanking, riskDistribution, patternStats };
|
||||
}, [rawData]);
|
||||
|
||||
const filteredSuppliers = useMemo(() => {
|
||||
if (!processedData) return [];
|
||||
let list = processedData.suppliers;
|
||||
if (filterRisk !== 'all') list = list.filter(s => s.risk_level === filterRisk);
|
||||
if (filterPattern !== 'all') list = list.filter(s => s.risk_pattern === filterPattern);
|
||||
return list;
|
||||
}, [processedData, filterRisk, filterPattern]);
|
||||
|
||||
const paginatedSuppliers = useMemo(() => {
|
||||
const start = (supplierPage - 1) * pageSize;
|
||||
return filteredSuppliers.slice(start, start + pageSize);
|
||||
}, [filteredSuppliers, supplierPage]);
|
||||
|
||||
const paginatedOrders = useMemo(() => {
|
||||
if (!processedData) return [];
|
||||
const start = (orderPage - 1) * pageSize;
|
||||
return processedData.highRiskOrders.slice(start, start + pageSize);
|
||||
}, [processedData, orderPage]);
|
||||
|
||||
const supplierTotalPages = Math.ceil(filteredSuppliers.length / pageSize);
|
||||
const orderTotalPages = Math.ceil((processedData?.highRiskOrders?.length || 0) / pageSize);
|
||||
|
||||
// 渲染图表
|
||||
useEffect(() => {
|
||||
if (!processedData || !window.G2) return;
|
||||
|
||||
[chartInstance1, chartInstance3].forEach(ref => {
|
||||
if (ref.current) { ref.current.destroy(); ref.current = null; }
|
||||
});
|
||||
|
||||
if (chartRef1.current && Object.values(processedData.riskDistribution).some(v => v > 0)) {
|
||||
const riskData = Object.entries(processedData.riskDistribution)
|
||||
.filter(([_, v]) => v > 0)
|
||||
.map(([level, count]) => ({ name: riskLevelConfig[level]?.label || level, value: count }));
|
||||
|
||||
chartInstance1.current = new G2.Chart({ container: chartRef1.current, autoFit: true, height: 180 });
|
||||
chartInstance1.current.coordinate({ type: 'theta', innerRadius: 0.6 });
|
||||
chartInstance1.current.interval()
|
||||
.data(riskData)
|
||||
.transform({ type: 'stackY' })
|
||||
.encode('y', 'value').encode('color', 'name')
|
||||
.scale('color', { range: ['#ef4444', '#f59e0b', '#10b981'] })
|
||||
.style('stroke', '#fff').style('lineWidth', 2)
|
||||
.label({ text: d => d.value > 0 ? `${d.name}\n${d.value}家` : '', position: 'outside', fontSize: 10 })
|
||||
.legend(false)
|
||||
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 家供应商' }] });
|
||||
chartInstance1.current.render();
|
||||
}
|
||||
|
||||
if (chartRef3.current && processedData.suppliers.length > 0) {
|
||||
const topRiskSuppliers = [...processedData.suppliers]
|
||||
.filter(s => s.total_risk_score > 0)
|
||||
.sort((a, b) => b.total_risk_score - a.total_risk_score)
|
||||
.slice(0, 6)
|
||||
.map(s => ({ name: s.supplier_name.length > 4 ? s.supplier_name.slice(0, 4) + '..' : s.supplier_name, 风险分: s.total_risk_score, level: s.risk_level }));
|
||||
|
||||
if (topRiskSuppliers.length > 0) {
|
||||
chartInstance3.current = new G2.Chart({ container: chartRef3.current, autoFit: true, height: 180 });
|
||||
chartInstance3.current.interval()
|
||||
.data(topRiskSuppliers)
|
||||
.encode('x', 'name').encode('y', '风险分').encode('color', 'level')
|
||||
.scale('color', { domain: ['HIGH', 'MEDIUM', 'LOW'], range: ['#ef4444', '#f59e0b', '#10b981'] })
|
||||
.scale('y', { domain: [0, 100] })
|
||||
.style('radius', 6)
|
||||
.axis('x', { labelFontSize: 10 })
|
||||
.axis('y', { title: false })
|
||||
.legend(false)
|
||||
.tooltip({ items: [{ channel: 'y', valueFormatter: v => v + ' 分' }] });
|
||||
chartInstance3.current.render();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
[chartInstance1, chartInstance3].forEach(ref => {
|
||||
if (ref.current) { ref.current.destroy(); ref.current = null; }
|
||||
});
|
||||
};
|
||||
}, [processedData]);
|
||||
|
||||
useEffect(() => {
|
||||
const helper = new ChildPageHelper({
|
||||
autoRenderStatus: true, enableLog: config.enableLog,
|
||||
onReady: async () => {
|
||||
await helper.autoInitialize(() => {
|
||||
const finalInitData = helper.parseTemplateData(lzwcaiComInitDate, defaultData);
|
||||
if (finalInitData) {
|
||||
const v = validateData(finalInitData);
|
||||
v.valid ? (setRawData(finalInitData), setError(null)) : setError(v.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setPageHelper(helper);
|
||||
helper.expose({
|
||||
setDataLzwcaiEmbedFrameFn: (data) => {
|
||||
const v = validateData(data);
|
||||
if (v.valid) { setRawData(data); setError(null); return { status: 'success' }; }
|
||||
setError(v.error); return { status: 'error', message: v.error };
|
||||
},
|
||||
getDataLzwcaiEmbedFrameFn: () => ({ status: 'success', data: rawData }),
|
||||
captureScreenshotLzwcaiEmbedFrameFn: helper.createScreenshotMethod(),
|
||||
getRenderStatusLzwcaiEmbedFrameFn: () => helper.getRenderStatus()
|
||||
});
|
||||
}, []);
|
||||
|
||||
const formatPercent = v => (v || 0).toFixed(1) + '%';
|
||||
const getRisk = level => riskLevelConfig[level] || riskLevelConfig['LOW'];
|
||||
const getPattern = pattern => riskPatternConfig[pattern] || riskPatternConfig['NORMAL'];
|
||||
const getAction = action => actionConfig[action] || actionConfig['NORMAL'];
|
||||
const getAssessment = assessment => assessmentConfig[assessment] || assessmentConfig['EXCELLENT'];
|
||||
|
||||
// 订单详情弹窗内容
|
||||
const renderOrderDetail = (order) => {
|
||||
const rc = getRisk(order.risk_level);
|
||||
const ac = getAction(order.action_required);
|
||||
const pc = getPattern(order.risk_pattern);
|
||||
return html`
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 p-4 rounded-xl ${order.risk_level === 'HIGH' ? 'bg-red-50' : 'bg-amber-50'}">
|
||||
<span class="text-3xl">${rc.icon}</span>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-slate-800">${order.purchase_order_number}</p>
|
||||
<p class="text-sm text-slate-600">${order.supplier_name}</p>
|
||||
</div>
|
||||
<span class="ml-auto px-3 py-1.5 ${ac.bg} rounded-lg text-sm font-medium ${ac.color}">${ac.icon} ${ac.label}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 bg-slate-50 rounded-xl">
|
||||
<p class="text-xs text-slate-500 mb-1">供应商类别</p>
|
||||
<p class="font-semibold text-slate-800">${order.supplier_category || '-'}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl">
|
||||
<p class="text-xs text-slate-500 mb-1">风险等级</p>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-sm font-medium ${rc.light} ${rc.text}">${rc.icon} ${rc.label}</span>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl">
|
||||
<p class="text-xs text-slate-500 mb-1">下单日期</p>
|
||||
<p class="font-semibold text-slate-800">${order.order_date || '-'}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl">
|
||||
<p class="text-xs text-slate-500 mb-1">已等待天数</p>
|
||||
<p class="font-semibold ${order.days_since_order > 30 ? 'text-red-600' : 'text-slate-800'}">${order.days_since_order} 天</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl">
|
||||
<p class="text-xs text-slate-500 mb-1">质量合格率</p>
|
||||
<p class="font-semibold ${order.quality_rate < 80 ? 'text-red-600' : order.quality_rate < 95 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(order.quality_rate)}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl">
|
||||
<p class="text-xs text-slate-500 mb-1">风险模式</p>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-sm font-medium ${pc.bg} ${pc.color}">${pc.icon} ${pc.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
${order.risk_factors && html`
|
||||
<div class="p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<p class="text-sm font-semibold text-orange-700 mb-2">⚠️ 风险因素</p>
|
||||
<p class="text-sm text-orange-600">${order.risk_factors}</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// 供应商详情弹窗内容
|
||||
const renderSupplierDetail = (s) => {
|
||||
const rc = getRisk(s.risk_level);
|
||||
const pc = getPattern(s.risk_pattern);
|
||||
return html`
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 p-4 rounded-xl ${s.risk_level === 'HIGH' ? 'bg-red-50' : s.risk_level === 'MEDIUM' ? 'bg-amber-50' : 'bg-emerald-50'}">
|
||||
<span class="text-3xl">${rc.icon}</span>
|
||||
<div class="flex-1">
|
||||
<p class="text-xl font-bold text-slate-800">${s.supplier_name}</p>
|
||||
<p class="text-sm text-slate-600">${s.supplier_category}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class="inline-flex items-center justify-center w-14 h-14 rounded-full text-xl font-bold ${s.total_risk_score >= 80 ? 'bg-red-100 text-red-700' : s.total_risk_score >= 50 ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'}">
|
||||
${s.total_risk_score}
|
||||
</span>
|
||||
<p class="text-xs text-slate-500 mt-1">风险评分</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">${s.order_count}</p>
|
||||
<p class="text-xs text-slate-500">订单数</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold text-emerald-600">${s.receipt_count}</p>
|
||||
<p class="text-xs text-slate-500">收货数</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold text-red-600">${s.return_count || 0}</p>
|
||||
<p class="text-xs text-slate-500">退货数</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold ${s.avg_delivery_days > 100 ? 'text-red-600' : 'text-slate-700'}">${s.avg_delivery_days}</p>
|
||||
<p class="text-xs text-slate-500">平均交期(天)</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold ${s.quality_rate < 80 ? 'text-red-600' : s.quality_rate < 95 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(s.quality_rate)}</p>
|
||||
<p class="text-xs text-slate-500">质量合格率</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-sm font-medium ${pc.bg} ${pc.color}">${pc.icon} ${pc.label}</span>
|
||||
<p class="text-xs text-slate-500 mt-1">风险模式</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-xl ${rc.light} ${rc.border} border">
|
||||
<span class="text-lg">${rc.icon}</span>
|
||||
<span class="font-medium ${rc.text}">风险等级: ${rc.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// 排名详情弹窗内容
|
||||
const renderRankingDetail = (r) => {
|
||||
const ac = getAssessment(r.risk_assessment);
|
||||
return html`
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-amber-50 to-orange-50">
|
||||
<span class="w-12 h-12 flex items-center justify-center rounded-xl text-xl font-bold ${r.risk_rank <= 3 ? 'bg-gradient-to-br from-amber-400 to-orange-500 text-white' : 'bg-slate-100 text-slate-600'}">${r.risk_rank}</span>
|
||||
<div class="flex-1">
|
||||
<p class="text-xl font-bold text-slate-800">${r.supplier_name}</p>
|
||||
<p class="text-sm text-slate-600">${r.supplier_category}</p>
|
||||
</div>
|
||||
<span class="px-3 py-1.5 rounded-lg text-sm font-medium ${ac.bg} ${ac.color}">${ac.label}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">${r.order_count || 0}</p>
|
||||
<p class="text-xs text-slate-500">订单数</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold text-emerald-600">${r.receipt_count || 0}</p>
|
||||
<p class="text-xs text-slate-500">收货数</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold text-red-600">${r.return_count || 0}</p>
|
||||
<p class="text-xs text-slate-500">退货数</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-xl text-center">
|
||||
<p class="text-2xl font-bold ${r.return_rate > 20 ? 'text-red-600' : r.return_rate > 10 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(r.return_rate)}</p>
|
||||
<p class="text-xs text-slate-500">退货率</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
if (error) return html`
|
||||
<div class="page-bg flex items-center justify-center p-6">
|
||||
<div class="glass rounded-3xl shadow-2xl p-8 max-w-sm text-center border border-red-100">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-400 to-rose-500 rounded-2xl flex items-center justify-center text-3xl shadow-lg">⚠️</div>
|
||||
<h3 class="text-lg font-bold text-red-700 mb-2">数据异常</h3>
|
||||
<p class="text-sm text-red-500">${error}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (!processedData) return html`
|
||||
<div class="page-bg flex items-center justify-center p-6">
|
||||
<div class="glass rounded-3xl shadow-xl p-10 max-w-sm text-center border border-orange-100">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-orange-400 to-red-500 rounded-2xl flex items-center justify-center text-3xl animate-pulse">🔗</div>
|
||||
<h3 class="text-lg font-bold text-orange-600 mb-2">加载中</h3>
|
||||
<p class="text-sm text-slate-400">等待供应链数据...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const { suppliers, highRiskOrders, categoryRisk, monthlyTrend, metrics, riskRanking, riskDistribution, patternStats } = processedData;
|
||||
|
||||
return html`
|
||||
<div class="page-bg p-4 sm:p-5">
|
||||
<div class="max-w-7xl mx-auto space-y-4">
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<${Modal} isOpen=${modalType === 'order'} onClose=${closeModal} title="订单详情" icon="📦">
|
||||
${modalData && renderOrderDetail(modalData)}
|
||||
<//>
|
||||
<${Modal} isOpen=${modalType === 'supplier'} onClose=${closeModal} title="供应商详情" icon="🏭">
|
||||
${modalData && renderSupplierDetail(modalData)}
|
||||
<//>
|
||||
<${Modal} isOpen=${modalType === 'ranking'} onClose=${closeModal} title="排名详情" icon="🏆">
|
||||
${modalData && renderRankingDetail(modalData)}
|
||||
<//>
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="glass rounded-2xl p-5 shadow-lg border border-white/60">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-red-500 via-orange-500 to-amber-500 rounded-2xl flex items-center justify-center shadow-lg shadow-red-500/30">
|
||||
<span class="text-white text-2xl">🔗</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold gradient-text">${config.title}</h1>
|
||||
<p class="text-xs text-slate-500 mt-0.5">供应商监控 · 交期预警 · 质量追踪</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
${(() => {
|
||||
const highRisk = parseInt(metrics.high_risk_suppliers?.metric_value || 0);
|
||||
const overdueOrders = parseInt(metrics.overdue_orders_30d?.metric_value || 0);
|
||||
const recentReturns = parseInt(metrics.recent_returns_30d?.metric_value || 0);
|
||||
return html`
|
||||
${highRisk > 0 && html`
|
||||
<span class="px-4 py-2 bg-red-50 border border-red-200 rounded-xl text-sm font-bold text-red-600 flex items-center gap-2 animate-pulse">
|
||||
<span class="w-2.5 h-2.5 bg-red-500 rounded-full animate-blink"></span>
|
||||
${highRisk} 高风险供应商
|
||||
</span>
|
||||
`}
|
||||
${overdueOrders > 0 && html`
|
||||
<span class="px-4 py-2 bg-orange-50 border border-orange-200 rounded-xl text-sm font-bold text-orange-600 flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 bg-orange-500 rounded-full"></span>
|
||||
${overdueOrders} 超期订单
|
||||
</span>
|
||||
`}
|
||||
${recentReturns > 0 && html`
|
||||
<span class="px-4 py-2 bg-purple-50 border border-purple-200 rounded-xl text-sm font-bold text-purple-600 flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 bg-purple-500 rounded-full"></span>
|
||||
${recentReturns} 近期退货
|
||||
</span>
|
||||
`}
|
||||
`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 核心指标卡片 -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
${[
|
||||
{ key: 'high_risk_suppliers', icon: '🔴', label: '高风险供应商', color: 'from-red-500 to-rose-600', shadow: 'shadow-red-200/50', alert: true },
|
||||
{ key: 'medium_risk_suppliers', icon: '🟡', label: '中风险供应商', color: 'from-amber-400 to-orange-500', shadow: 'shadow-amber-200/50' },
|
||||
{ key: 'low_risk_suppliers', icon: '🟢', label: '低风险供应商', color: 'from-emerald-400 to-teal-500', shadow: 'shadow-emerald-200/50' },
|
||||
{ key: 'pending_orders', icon: '📦', label: '待收货订单', color: 'from-blue-400 to-indigo-500', shadow: 'shadow-blue-200/50' },
|
||||
{ key: 'overdue_orders_30d', icon: '⏰', label: '超期订单(30天)', color: 'from-orange-400 to-red-500', shadow: 'shadow-orange-200/50', alert: true },
|
||||
{ key: 'recent_returns_30d', icon: '↩️', label: '近期退货(30天)', color: 'from-purple-400 to-pink-500', shadow: 'shadow-purple-200/50' }
|
||||
].map((item, i) => {
|
||||
const m = metrics[item.key] || {};
|
||||
const isAlert = item.alert && parseInt(m.metric_value || 0) > 0;
|
||||
const status = m.status;
|
||||
return html`
|
||||
<div key=${i} class="bg-white rounded-xl p-4 shadow-md border border-slate-100/80 hover:shadow-lg transition-all animate-slide-in ${isAlert ? 'ring-2 ring-red-200 bg-red-50/30' : ''}" style=${{ animationDelay: i * 50 + 'ms' }}>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="w-10 h-10 bg-gradient-to-br ${item.color} rounded-xl flex items-center justify-center text-base shadow-md ${item.shadow} ${isAlert ? 'animate-shake' : ''}">${item.icon}</span>
|
||||
${status && status !== 'NORMAL' && html`
|
||||
<span class="px-1.5 py-0.5 text-xs rounded ${status === 'ATTENTION_NEEDED' ? 'bg-red-100 text-red-600' : status === 'DELIVERY_WARNING' ? 'bg-orange-100 text-orange-600' : 'bg-slate-100 text-slate-600'}">
|
||||
${status === 'ATTENTION_NEEDED' ? '需关注' : status === 'DELIVERY_WARNING' ? '交期预警' : status}
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
<p class="text-2xl font-black ${isAlert ? 'text-red-600' : 'text-slate-800'}">${m.metric_value || '0'}</p>
|
||||
<p class="text-xs text-slate-500 mt-0.5">${item.label}</p>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="grid lg:grid-cols-4 gap-4">
|
||||
<!-- 风险分布 -->
|
||||
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
|
||||
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<span class="w-6 h-6 bg-gradient-to-br from-red-400 to-rose-500 rounded-lg flex items-center justify-center text-white text-xs">📊</span>
|
||||
风险等级分布
|
||||
</h3>
|
||||
${Object.values(riskDistribution).some(v => v > 0)
|
||||
? html`<div ref=${chartRef1} class="w-full"></div>`
|
||||
: html`<${EmptyState} icon="📊" title="暂无风险数据" desc="等待数据加载" />`
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 类别质量合格率 -->
|
||||
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
|
||||
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<span class="w-6 h-6 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg flex items-center justify-center text-white text-xs">📈</span>
|
||||
类别质量合格率
|
||||
</h3>
|
||||
${categoryRisk.length > 0 ? html`
|
||||
<div class="space-y-2 max-h-[180px] overflow-y-auto scrollbar-thin pr-1">
|
||||
${[...categoryRisk].sort((a, b) => a.quality_rate - b.quality_rate).slice(0, 6).map((c, i) => {
|
||||
const rc = getRisk(c.category_risk_level);
|
||||
return html`
|
||||
<div key=${i} class="flex items-center gap-2 p-2 rounded-lg ${c.category_risk_level === 'HIGH' ? 'bg-red-50' : 'bg-slate-50'} hover:shadow-sm transition-all">
|
||||
<span class="w-2 h-2 rounded-full ${rc.dot}"></span>
|
||||
<span class="flex-1 text-xs font-medium text-slate-700 truncate">${c.supplier_category}</span>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="w-16 h-1.5 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all ${c.quality_rate < 50 ? 'bg-red-500' : c.quality_rate < 80 ? 'bg-orange-500' : c.quality_rate < 95 ? 'bg-amber-500' : 'bg-emerald-500'}" style=${{ width: Math.min(c.quality_rate, 100) + '%' }}></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold w-12 text-right ${c.quality_rate < 50 ? 'text-red-600' : c.quality_rate < 80 ? 'text-orange-600' : c.quality_rate < 95 ? 'text-amber-600' : 'text-emerald-600'}">${formatPercent(c.quality_rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : html`<${EmptyState} icon="📈" title="暂无类别数据" desc="等待数据加载" />`}
|
||||
</div>
|
||||
|
||||
<!-- 风险评分TOP -->
|
||||
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
|
||||
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<span class="w-6 h-6 bg-gradient-to-br from-purple-400 to-pink-500 rounded-lg flex items-center justify-center text-white text-xs">⚡</span>
|
||||
风险评分TOP
|
||||
</h3>
|
||||
${suppliers.filter(s => s.total_risk_score > 0).length > 0
|
||||
? html`<div ref=${chartRef3} class="w-full"></div>`
|
||||
: html`<${EmptyState} icon="⚡" title="暂无评分数据" desc="等待数据加载" />`
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 月度趋势 -->
|
||||
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
|
||||
<h3 class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<span class="w-6 h-6 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-lg flex items-center justify-center text-white text-xs">📉</span>
|
||||
月度趋势
|
||||
</h3>
|
||||
${monthlyTrend.length > 0 ? html`
|
||||
<div class="space-y-2">
|
||||
${monthlyTrend.map((t, i) => html`
|
||||
<div key=${i} class="p-3 rounded-xl bg-gradient-to-r from-slate-50 to-white border border-slate-100">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-bold text-slate-700">${t.month_start?.slice(0, 7) || '未知'}</span>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium ${t.return_rate > 20 ? 'bg-red-100 text-red-600' : t.return_rate > 10 ? 'bg-amber-100 text-amber-600' : 'bg-emerald-100 text-emerald-600'}">
|
||||
退货率 ${formatPercent(t.return_rate)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="text-center p-1.5 bg-blue-50 rounded">
|
||||
<p class="text-blue-600 font-bold">${t.receipt_count}</p>
|
||||
<p class="text-slate-500">收货数</p>
|
||||
</div>
|
||||
<div class="text-center p-1.5 bg-orange-50 rounded">
|
||||
<p class="text-orange-600 font-bold">${t.avg_delivery_days?.toFixed(0) || 0}天</p>
|
||||
<p class="text-slate-500">平均交期</p>
|
||||
</div>
|
||||
<div class="text-center p-1.5 bg-red-50 rounded">
|
||||
<p class="text-red-600 font-bold">${t.return_count}</p>
|
||||
<p class="text-slate-500">退货数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : html`<${EmptyState} icon="📉" title="暂无趋势数据" desc="等待数据加载" />`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高风险订单预警 - 简化卡片,点击查看详情 -->
|
||||
<div class="bg-white rounded-2xl shadow-md border border-slate-100/80 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||
<span class="w-6 h-6 bg-gradient-to-br from-red-400 to-rose-500 rounded-lg flex items-center justify-center text-white text-xs animate-blink">🚨</span>
|
||||
高风险订单预警
|
||||
<span class="text-xs text-slate-400 font-normal ml-2">点击卡片查看详情</span>
|
||||
</h3>
|
||||
<span class="px-2 py-1 bg-red-50 text-red-600 rounded-full text-xs font-medium">${highRiskOrders.length} 项</span>
|
||||
</div>
|
||||
${highRiskOrders.length > 0 ? html`
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
|
||||
${paginatedOrders.map((order, i) => {
|
||||
const rc = getRisk(order.risk_level);
|
||||
const ac = getAction(order.action_required);
|
||||
return html`
|
||||
<div key=${i} onClick=${() => openModal('order', order)}
|
||||
class="clickable-card bg-gradient-to-r ${order.risk_level === 'HIGH' ? 'from-red-50 to-orange-50 border-red-100' : 'from-amber-50 to-yellow-50 border-amber-100'} rounded-xl p-3 border animate-slide-in"
|
||||
style=${{ animationDelay: i * 60 + 'ms' }}>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-lg">${rc.icon}</span>
|
||||
<span class="font-bold text-slate-800 text-sm truncate flex-1">${order.purchase_order_number}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-600 truncate mb-1">${order.supplier_name}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-orange-600">已等 ${order.days_since_order} 天</span>
|
||||
<span class="text-xs text-slate-400">点击详情 →</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${orderTotalPages > 1 && html`
|
||||
<div class="mt-3 flex items-center justify-center gap-2">
|
||||
<button onClick=${() => setOrderPage(p => Math.max(1, p - 1))} disabled=${orderPage === 1}
|
||||
class="px-3 py-1 text-xs rounded-lg border border-slate-200 hover:bg-slate-50 disabled:opacity-50 transition-colors">上一页</button>
|
||||
<span class="text-xs text-slate-500">${orderPage}/${orderTotalPages}</span>
|
||||
<button onClick=${() => setOrderPage(p => Math.min(orderTotalPages, p + 1))} disabled=${orderPage === orderTotalPages}
|
||||
class="px-3 py-1 text-xs rounded-lg border border-slate-200 hover:bg-slate-50 disabled:opacity-50 transition-colors">下一页</button>
|
||||
</div>
|
||||
`}
|
||||
` : html`<${EmptyState} icon="✨" title="暂无高风险订单" desc="供应链运行正常" />`}
|
||||
</div>
|
||||
@@ -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>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -157,3 +157,52 @@
|
||||
2026-01-08 11:59:37 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: MetricTrendAndTurningPointWarning
|
||||
2026-01-08 11:59:37 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-08 11:59:37 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:362] - 开始运行 MCP SQL Executor 服务器
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:313] - ============================================================
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:314] - 正在启动 MCP 服务器: lzwcai-mcpskills-analyzeOrder
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:315] - 版本: 0.1.0
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:316] - ============================================================
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:320] - 环境配置 - Database ID: 19
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:321] - 环境配置 - Datasource ID: 19
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:322] - 环境配置 - Skill ID:
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:323] - 环境配置 - Backend Base URL: http://192.168.11.24:8088
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:324] - ============================================================
|
||||
2026-01-09 22:50:49 - mcp_services - INFO - [main.py:329] - MCP 服务器已启动,等待客户端连接...
|
||||
2026-01-09 22:50:56 - mcp_services - INFO - [main.py:156] - 收到列出工具请求
|
||||
2026-01-09 22:50:56 - mcp_services - INFO - [main.py:119] - 初始化查询配置(数据源: local)...
|
||||
2026-01-09 22:50:56 - mcp_services - INFO - [main.py:55] - 成功加载 6 个业务查询配置
|
||||
2026-01-09 22:50:56 - mcp_services - INFO - [main.py:123] - 本地配置: 6 条
|
||||
2026-01-09 22:50:56 - mcp_services - INFO - [main.py:165] - 成功生成 6 个 MCP 工具
|
||||
2026-01-09 22:50:57 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: OrderDelayWarningAnalysis
|
||||
2026-01-09 22:50:57 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 22:50:57 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 22:51:01 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: WorkOrderProgressAndAnomalyNodes
|
||||
2026-01-09 22:51:01 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 22:51:01 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 22:51:06 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: SupplyChainRiskWarning
|
||||
2026-01-09 22:51:06 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 22:51:06 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 22:51:14 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: SupplyChainRiskWarning
|
||||
2026-01-09 22:51:14 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 22:51:14 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 22:51:22 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: OnePageDecisionBrief
|
||||
2026-01-09 22:51:22 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 22:51:22 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 22:52:54 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: OrderDelayWarningAnalysis
|
||||
2026-01-09 22:52:54 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 22:52:54 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 23:05:38 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: WorkOrderProgressAndAnomalyNodes
|
||||
2026-01-09 23:05:38 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 23:05:39 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-09 23:48:54 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: SupplyChainRiskWarning
|
||||
2026-01-09 23:48:54 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-09 23:48:54 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-10 00:10:04 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: EfficiencyOutputLossDashboard
|
||||
2026-01-10 00:10:04 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-10 00:10:04 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-10 00:26:28 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: OnePageDecisionBrief
|
||||
2026-01-10 00:26:28 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-10 00:26:28 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
2026-01-10 00:47:45 - mcp_services - INFO - [main.py:190] - 收到工具调用请求: MetricTrendAndTurningPointWarning
|
||||
2026-01-10 00:47:45 - mcp_services - INFO - [main.py:225] - 正在调用测试SQL API...
|
||||
2026-01-10 00:47:46 - mcp_services - INFO - [main.py:227] - 测试SQL API调用成功
|
||||
|
||||
@@ -128,7 +128,7 @@ class DataSourceAPIClient:
|
||||
Exception: 请求失败时抛出
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/datasource/sqlExecutionLog/testSqlWithSchema"
|
||||
url = f"{self.base_url}/datasource/sqlExecutionLog/testBatchSqlWithSchema"
|
||||
|
||||
# 构建请求头(包含Content-Type)
|
||||
headers = self._get_headers()
|
||||
@@ -154,22 +154,36 @@ class DataSourceAPIClient:
|
||||
logger.info(f"测试SQL API调用成功")
|
||||
logger.debug(f"响应数据: {json.dumps(result, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# 处理返回数据结构: {code, data: {errorMessage, data}, msg}
|
||||
# 处理返回数据结构: {code, data: [{errorMessage, data}, ...], msg}
|
||||
# 检查外层 code
|
||||
if result.get("code") != 200:
|
||||
error_msg = result.get("msg", "接口返回错误")
|
||||
logger.error(f"接口返回错误: code={result.get('code')}, msg={error_msg}")
|
||||
raise Exception(error_msg)
|
||||
|
||||
# 检查内层 errorMessage
|
||||
inner_data = result.get("data", {})
|
||||
if inner_data.get("errorMessage"):
|
||||
error_msg = inner_data.get("errorMessage")
|
||||
logger.error(f"接口业务错误: {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
# data 是一个数组,每个元素包含 errorMessage 和 data
|
||||
data_list = result.get("data", [])
|
||||
|
||||
# 返回 data.data
|
||||
return inner_data.get("data")
|
||||
# 如果不是数组,兼容旧格式
|
||||
if isinstance(data_list, dict):
|
||||
if data_list.get("errorMessage"):
|
||||
error_msg = data_list.get("errorMessage")
|
||||
logger.error(f"接口业务错误: {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
return data_list.get("data")
|
||||
|
||||
# 处理数组格式,提取每个 item 的 data 并组合
|
||||
combined_data = []
|
||||
for item in data_list:
|
||||
if item.get("errorMessage"):
|
||||
error_msg = item.get("errorMessage")
|
||||
logger.error(f"接口业务错误: {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
item_data = item.get("data", [])
|
||||
combined_data.append(item_data)
|
||||
|
||||
# 返回组合后的数据 [data1, data2, ...]
|
||||
return combined_data
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_msg = f"测试SQL API请求超时: {url}"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "lzwcai-mcpskills-mfg-data-agent"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "制造业数据智能体 - MCP server for manufacturing data intelligence with dynamic tool generation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
Reference in New Issue
Block a user