feat(analytics): 设备生命周期时间轴(主干居中 + 主次分支 + 上帝视角 tooltip)
- flowOption 从堆叠柱状改为 custom 区间甘特图 - 灰色主干打底居中,主线任务绝对居中、分支任务悬挂右侧(靠透明度区分并行) - tooltip 上帝视角:开始时间/流转周期/累计工时/中间空闲时长/人员占比(色点标识)
This commit is contained in:
@ -292,74 +292,173 @@ export default function AnalyticsDashboard() {
|
|||||||
return set;
|
return set;
|
||||||
}, [capability]);
|
}, [capability]);
|
||||||
|
|
||||||
// ─── 柱状图:设备人员耗时对比(按人堆叠 + 柱顶总计 + tooltip 揪元凶) ──
|
// ─── 生命周期时间轴(主干延续 + 分支贴两侧 + 上帝视角 Tooltip) ──
|
||||||
const flowOption = useMemo<EChartsCoreOption>(() => {
|
const flowOption = useMemo<EChartsCoreOption>(() => {
|
||||||
const devices = flow?.devices ?? [];
|
const devices = flow?.devices ?? [];
|
||||||
const series = flow?.series ?? [];
|
const series = flow?.series ?? [];
|
||||||
// 每台设备总耗时(各人员累加)
|
|
||||||
const totalData = devices.map((_, i) => {
|
// 分支配色(与人员一一对应,用于柱子和 tooltip 色点)
|
||||||
let sum = 0;
|
const BRANCH_COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16"];
|
||||||
for (const s of series) {
|
const colorMap: Record<string, string> = {};
|
||||||
const v = s.data[i];
|
series.forEach((s, index) => {
|
||||||
if (v != null) sum += v;
|
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 {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
axisPointer: { type: "shadow" },
|
axisPointer: { type: "shadow", shadowStyle: { color: "rgba(0,0,0,0.05)" } },
|
||||||
formatter: (p: any) => {
|
formatter: (params: any) => {
|
||||||
const raw = Array.isArray(p) ? p : [p];
|
const deviceIndex = params[0].dataIndex;
|
||||||
const idx = raw[0]?.dataIndex ?? 0;
|
const device = devices[deviceIndex];
|
||||||
const items = raw.filter((it: any) => it.seriesName !== "总计" && it.value != null);
|
const stats = deviceStats[deviceIndex];
|
||||||
const d = devices[idx];
|
if (!device || !stats) return "";
|
||||||
const total = items.reduce((sum: number, it: any) => sum + it.value, 0);
|
|
||||||
const head = d
|
let html = `<div style="font-size:14px;font-weight:bold;color:#1f2937;margin-bottom:4px;">${device.spec_model || "未知型号"}</div>`;
|
||||||
? `${d.material_name || "设备"} · ${d.spec_model || "—"}<br/>身份证:${d.product_sn}<br/>`
|
html += `<div style="color:#6b7280;font-size:12px;margin-bottom:8px;">身份证: ${device.product_sn}</div>`;
|
||||||
: "";
|
html += `<hr style="margin:8px 0;border-color:#e5e7eb" />`;
|
||||||
const body = items
|
|
||||||
.map((it: any) => {
|
if (device.started_at) {
|
||||||
const pct = total > 0 ? ((it.value / total) * 100).toFixed(1) : "0.0";
|
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>开始时间:</span><b style="color:#111827">${device.started_at}</b></div>`;
|
||||||
return `${it.marker}${it.seriesName}:${it.value} 小时(${pct}%)`;
|
}
|
||||||
})
|
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>实际流转周期:</span><b style="color:#111827">${stats.leadTime} h</b></div>`;
|
||||||
.join("<br/>");
|
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>`;
|
||||||
return head + body;
|
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) },
|
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: {
|
xAxis: {
|
||||||
type: "category",
|
type: "category",
|
||||||
data: devices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
|
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: [
|
||||||
...series.map((s, si) => ({
|
// 1. 背景主干 (The Trunk) - 连贯的浅灰底
|
||||||
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,
|
|
||||||
})),
|
|
||||||
{
|
{
|
||||||
name: "总计",
|
name: "总生命周期",
|
||||||
type: "bar" as const,
|
type: "bar" as const,
|
||||||
barGap: "-100%",
|
barMaxWidth: 32,
|
||||||
barMaxWidth: 40,
|
z: 1,
|
||||||
itemStyle: { color: "transparent" },
|
itemStyle: { color: "#E5E7EB", borderColor: "#9CA3AF", borderWidth: 1, borderRadius: [4, 4, 0, 0] },
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
position: "top",
|
position: "top",
|
||||||
formatter: "{c}h",
|
formatter: (p: any) => `{title|总计}\n{value|${p.value}h}`,
|
||||||
fontWeight: "bold",
|
rich: {
|
||||||
color: "#333",
|
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]);
|
}, [flow]);
|
||||||
@ -494,7 +593,7 @@ export default function AnalyticsDashboard() {
|
|||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
<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 ? (
|
{flowLoading && !flow ? (
|
||||||
<div className="flex h-[380px] items-center justify-center">
|
<div className="flex h-[380px] items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
|||||||
Reference in New Issue
Block a user