feat(analytics): 设备生命周期时间轴(主干居中 + 主次分支 + 上帝视角 tooltip)
- flowOption 从堆叠柱状改为 custom 区间甘特图 - 灰色主干打底居中,主线任务绝对居中、分支任务悬挂右侧(靠透明度区分并行) - tooltip 上帝视角:开始时间/流转周期/累计工时/中间空闲时长/人员占比(色点标识)
This commit is contained in:
@ -292,74 +292,173 @@ export default function AnalyticsDashboard() {
|
||||
return set;
|
||||
}, [capability]);
|
||||
|
||||
// ─── 柱状图:设备人员耗时对比(按人堆叠 + 柱顶总计 + tooltip 揪元凶) ──
|
||||
// ─── 生命周期时间轴(主干延续 + 分支贴两侧 + 上帝视角 Tooltip) ──
|
||||
const flowOption = useMemo<EChartsCoreOption>(() => {
|
||||
const devices = flow?.devices ?? [];
|
||||
const series = flow?.series ?? [];
|
||||
// 每台设备总耗时(各人员累加)
|
||||
const totalData = devices.map((_, i) => {
|
||||
let sum = 0;
|
||||
for (const s of series) {
|
||||
const v = s.data[i];
|
||||
if (v != null) sum += v;
|
||||
|
||||
// 分支配色(与人员一一对应,用于柱子和 tooltip 色点)
|
||||
const BRANCH_COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16"];
|
||||
const colorMap: Record<string, string> = {};
|
||||
series.forEach((s, index) => {
|
||||
colorMap[s.name] = BRANCH_COLORS[index % BRANCH_COLORS.length];
|
||||
});
|
||||
|
||||
// 1. 预计算每台设备的统计:累计投入、空闲时长、人员排序
|
||||
const deviceStats = devices.map((d, idx) => {
|
||||
let totalInputHours = 0;
|
||||
const personHours: Record<string, number> = {};
|
||||
const intervals: [number, number][] = [];
|
||||
|
||||
series.forEach((s) => {
|
||||
s.data.forEach((task: any) => {
|
||||
if (task[0] === idx) {
|
||||
totalInputHours += task[4];
|
||||
personHours[s.name] = (personHours[s.name] || 0) + task[4];
|
||||
intervals.push([task[1], task[2]]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 合并重叠区间,计算空闲时长(生命周期内未被任务覆盖的部分)
|
||||
intervals.sort((a, b) => a[0] - b[0]);
|
||||
let covered = 0;
|
||||
let mergedEnd = -Infinity;
|
||||
for (const [s, e] of intervals) {
|
||||
if (s > mergedEnd) {
|
||||
covered += e - s;
|
||||
mergedEnd = e;
|
||||
} else if (e > mergedEnd) {
|
||||
covered += e - mergedEnd;
|
||||
mergedEnd = e;
|
||||
}
|
||||
}
|
||||
return Math.round(sum * 10) / 10;
|
||||
const idleTime = Math.max((d.lead_time || 0) - covered, 0);
|
||||
|
||||
const sortedPeople = Object.entries(personHours).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
return {
|
||||
leadTime: d.lead_time || 0,
|
||||
totalInputHours,
|
||||
idleTime,
|
||||
sortedPeople,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
formatter: (p: any) => {
|
||||
const raw = Array.isArray(p) ? p : [p];
|
||||
const idx = raw[0]?.dataIndex ?? 0;
|
||||
const items = raw.filter((it: any) => it.seriesName !== "总计" && it.value != null);
|
||||
const d = devices[idx];
|
||||
const total = items.reduce((sum: number, it: any) => sum + it.value, 0);
|
||||
const head = d
|
||||
? `${d.material_name || "设备"} · ${d.spec_model || "—"}<br/>身份证:${d.product_sn}<br/>`
|
||||
: "";
|
||||
const body = items
|
||||
.map((it: any) => {
|
||||
const pct = total > 0 ? ((it.value / total) * 100).toFixed(1) : "0.0";
|
||||
return `${it.marker}${it.seriesName}:${it.value} 小时(${pct}%)`;
|
||||
})
|
||||
.join("<br/>");
|
||||
return head + body;
|
||||
axisPointer: { type: "shadow", shadowStyle: { color: "rgba(0,0,0,0.05)" } },
|
||||
formatter: (params: any) => {
|
||||
const deviceIndex = params[0].dataIndex;
|
||||
const device = devices[deviceIndex];
|
||||
const stats = deviceStats[deviceIndex];
|
||||
if (!device || !stats) return "";
|
||||
|
||||
let html = `<div style="font-size:14px;font-weight:bold;color:#1f2937;margin-bottom:4px;">${device.spec_model || "未知型号"}</div>`;
|
||||
html += `<div style="color:#6b7280;font-size:12px;margin-bottom:8px;">身份证: ${device.product_sn}</div>`;
|
||||
html += `<hr style="margin:8px 0;border-color:#e5e7eb" />`;
|
||||
|
||||
if (device.started_at) {
|
||||
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>开始时间:</span><b style="color:#111827">${device.started_at}</b></div>`;
|
||||
}
|
||||
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>实际流转周期:</span><b style="color:#111827">${stats.leadTime} h</b></div>`;
|
||||
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>累计投入工时:</span><b style="color:#2563eb">${stats.totalInputHours.toFixed(1)} h</b></div>`;
|
||||
html += `<div style="display:flex;justify-content:space-between;margin-bottom:12px;"><span>中间空闲时长:</span><b style="color:#f59e0b">${stats.idleTime.toFixed(1)} h</b></div>`;
|
||||
|
||||
if (stats.sortedPeople.length > 0) {
|
||||
html += `<div style="font-size:12px;color:#9ca3af;margin-bottom:6px;">人员耗时占比分析:</div>`;
|
||||
stats.sortedPeople.forEach(([name, hours]) => {
|
||||
const percentage = stats.totalInputHours > 0 ? ((hours / stats.totalInputHours) * 100).toFixed(1) : "0.0";
|
||||
const color = colorMap[name] || "#9CA3AF";
|
||||
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
|
||||
<span style="display:flex;align-items:center;color:#4b5563;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:6px;flex-shrink:0;"></span>${name}</span>
|
||||
<span style="margin-left:24px;color:#4b5563;">${hours.toFixed(1)}h (${percentage}%)</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: { top: 0, type: "scroll", data: series.map((s) => s.name) },
|
||||
grid: { left: 48, right: 24, top: 40, bottom: 72 },
|
||||
grid: { left: 48, right: 24, top: 60, bottom: 72 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: devices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
|
||||
axisLabel: { fontSize: 10, interval: 0 },
|
||||
axisLabel: { fontSize: 11, interval: 0, color: '#666', lineHeight: 14 },
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "设备生命周期 (小时)",
|
||||
min: 0,
|
||||
nameTextStyle: { color: '#999', padding: [0, 0, 0, 20] },
|
||||
splitLine: { lineStyle: { type: 'dashed', color: '#f3f4f6' } }
|
||||
},
|
||||
yAxis: { type: "value", name: "耗时(小时)" },
|
||||
series: [
|
||||
...series.map((s, si) => ({
|
||||
name: s.name,
|
||||
type: "bar" as const,
|
||||
stack: "total",
|
||||
barMaxWidth: 40,
|
||||
itemStyle: si === series.length - 1 ? { borderRadius: [4, 4, 0, 0] } : undefined,
|
||||
data: s.data,
|
||||
})),
|
||||
// 1. 背景主干 (The Trunk) - 连贯的浅灰底
|
||||
{
|
||||
name: "总计",
|
||||
name: "总生命周期",
|
||||
type: "bar" as const,
|
||||
barGap: "-100%",
|
||||
barMaxWidth: 40,
|
||||
itemStyle: { color: "transparent" },
|
||||
barMaxWidth: 32,
|
||||
z: 1,
|
||||
itemStyle: { color: "#E5E7EB", borderColor: "#9CA3AF", borderWidth: 1, borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: "top",
|
||||
formatter: "{c}h",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
formatter: (p: any) => `{title|总计}\n{value|${p.value}h}`,
|
||||
rich: {
|
||||
title: { fontSize: 12, color: "#999", padding: [0, 0, 2, 0] },
|
||||
value: { fontSize: 14, fontWeight: "bold", color: "#1f2937" },
|
||||
},
|
||||
},
|
||||
data: totalData,
|
||||
data: devices.map((d) => d.lead_time || 0),
|
||||
},
|
||||
// 2. 任务分支 (The Branches) - 贴主干两侧
|
||||
...series.map((s, index) => ({
|
||||
name: s.name,
|
||||
type: "custom" as const,
|
||||
z: 2,
|
||||
dimensions: ['device', 'start', 'end', 'task', 'duration', 'is_main'],
|
||||
encode: { x: 0, y: [1, 2] },
|
||||
renderItem: (params: any, api: any) => {
|
||||
const categoryIndex = api.value(0);
|
||||
const rawData = s.data[params.dataIndex]; // [device, start, end, task, duration, is_main]
|
||||
|
||||
const start = api.coord([categoryIndex, rawData[1]]);
|
||||
const end = api.coord([categoryIndex, rawData[2]]);
|
||||
const isMain = rawData[5] === 1; // 解析后端传来的主次标识
|
||||
|
||||
const trunkWidth = 32; // 灰色背景主干的宽度
|
||||
const blockWidth = 20;
|
||||
|
||||
let x;
|
||||
if (isMain) {
|
||||
// 【主线任务】:绝对居中!盖在灰色主干的正中心
|
||||
x = start[0] - blockWidth / 2;
|
||||
} else {
|
||||
// 【分支任务】:悬挂在主干的右侧(+2px 缝隙避免粘连)
|
||||
x = start[0] + (trunkWidth / 2) + 2;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "rect",
|
||||
shape: {
|
||||
x: x,
|
||||
y: end[1], // ECharts y轴倒置
|
||||
width: blockWidth,
|
||||
height: Math.max(start[1] - end[1], 2), // 至少 2px 高度
|
||||
r: 2,
|
||||
},
|
||||
style: {
|
||||
fill: BRANCH_COLORS[index % BRANCH_COLORS.length],
|
||||
opacity: isMain ? 0.9 : 0.75, // 主线颜色更实,分支略透明
|
||||
},
|
||||
};
|
||||
},
|
||||
data: s.data,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}, [flow]);
|
||||
@ -494,7 +593,7 @@ export default function AnalyticsDashboard() {
|
||||
),
|
||||
children: (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-700">设备人员耗时对比 (按人堆叠)</h3>
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-700">设备生命周期时间轴(按人区间 · 并行任务并排显示)</h3>
|
||||
{flowLoading && !flow ? (
|
||||
<div className="flex h-[380px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
|
||||
Reference in New Issue
Block a user