From 99235d29e087e825641db1a2c2975270e72fb960 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Fri, 14 Aug 2026 11:44:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E8=AE=BE=E5=A4=87=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E6=97=B6=E9=97=B4=E8=BD=B4=EF=BC=88?= =?UTF-8?q?=E4=B8=BB=E5=B9=B2=E5=B1=85=E4=B8=AD=20+=20=E4=B8=BB=E6=AC=A1?= =?UTF-8?q?=E5=88=86=E6=94=AF=20+=20=E4=B8=8A=E5=B8=9D=E8=A7=86=E8=A7=92?= =?UTF-8?q?=20tooltip=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flowOption 从堆叠柱状改为 custom 区间甘特图 - 灰色主干打底居中,主线任务绝对居中、分支任务悬挂右侧(靠透明度区分并行) - tooltip 上帝视角:开始时间/流转周期/累计工时/中间空闲时长/人员占比(色点标识) --- .../src/pages/admin/AnalyticsDashboard.tsx | 189 +++++++++++++----- 1 file changed, 144 insertions(+), 45 deletions(-) diff --git a/frontend/src/pages/admin/AnalyticsDashboard.tsx b/frontend/src/pages/admin/AnalyticsDashboard.tsx index 1823b19..7b13b84 100644 --- a/frontend/src/pages/admin/AnalyticsDashboard.tsx +++ b/frontend/src/pages/admin/AnalyticsDashboard.tsx @@ -292,74 +292,173 @@ export default function AnalyticsDashboard() { return set; }, [capability]); - // ─── 柱状图:设备人员耗时对比(按人堆叠 + 柱顶总计 + tooltip 揪元凶) ── + // ─── 生命周期时间轴(主干延续 + 分支贴两侧 + 上帝视角 Tooltip) ── const flowOption = useMemo(() => { 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 = {}; + 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 = {}; + 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 || "—"}
身份证:${d.product_sn}
` - : ""; - 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("
"); - 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 = `
${device.spec_model || "未知型号"}
`; + html += `
身份证: ${device.product_sn}
`; + html += `
`; + + if (device.started_at) { + html += `
开始时间:${device.started_at}
`; + } + html += `
实际流转周期:${stats.leadTime} h
`; + html += `
累计投入工时:${stats.totalInputHours.toFixed(1)} h
`; + html += `
中间空闲时长:${stats.idleTime.toFixed(1)} h
`; + + if (stats.sortedPeople.length > 0) { + html += `
人员耗时占比分析:
`; + stats.sortedPeople.forEach(([name, hours]) => { + const percentage = stats.totalInputHours > 0 ? ((hours / stats.totalInputHours) * 100).toFixed(1) : "0.0"; + const color = colorMap[name] || "#9CA3AF"; + html += `
+ ${name} + ${hours.toFixed(1)}h (${percentage}%) +
`; + }); + } + 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: (
-

设备人员耗时对比 (按人堆叠)

+

设备生命周期时间轴(按人区间 · 并行任务并排显示)

{flowLoading && !flow ? (