feat(analytics): 设备生命周期时间轴(主干居中 + 主次分支 + 上帝视角 tooltip)

- flowOption 从堆叠柱状改为 custom 区间甘特图
- 灰色主干打底居中,主线任务绝对居中、分支任务悬挂右侧(靠透明度区分并行)
- tooltip 上帝视角:开始时间/流转周期/累计工时/中间空闲时长/人员占比(色点标识)
This commit is contained in:
2026-08-14 11:44:55 +08:00
parent 2b17dde42e
commit 99235d29e0

View File

@ -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" />