From 620df5ce11c081fa965b0f2e0ddb1be8d62c57de Mon Sep 17 00:00:00 2001 From: duxingchen Date: Fri, 14 Aug 2026 15:43:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E4=BA=BA=E5=91=98=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E9=87=8D=E6=9E=84=E4=B8=BA=20custom=20=E5=B1=85?= =?UTF-8?q?=E4=B8=AD=E7=B4=A7=E5=87=91=E6=9F=B1=E7=8A=B6=20+=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capabilityOption 改 custom 系列,居中紧凑算法消除幽灵占位 - 柱宽保底 3px(0值/极小值也有柱子)、数字标签悬浮柱顶 - X 轴四行展示:物料名/规格型号/序列号/身份证 - 原生横向滚动容器(每设备 140px)+ custom-scrollbar 样式 - tooltip appendToBody 避免被容器裁剪 --- frontend/src/index.css | 20 ++++ .../src/pages/admin/AnalyticsDashboard.tsx | 99 +++++++++++++------ 2 files changed, 88 insertions(+), 31 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 60c53b9..a4d179f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -53,3 +53,23 @@ body { .animate-slide-in { animation: slide-in 0.3s ease-out; } + +/* ============================================================ + 自定义横向滚动条(人员视图图表) + ============================================================ */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #cbd5e1 #f1f5f9; +} +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: #cbd5e1; + border-radius: 3px; +} +.custom-scrollbar::-webkit-scrollbar-track { + background-color: #f1f5f9; + border-radius: 3px; +} diff --git a/frontend/src/pages/admin/AnalyticsDashboard.tsx b/frontend/src/pages/admin/AnalyticsDashboard.tsx index d35962c..989873a 100644 --- a/frontend/src/pages/admin/AnalyticsDashboard.tsx +++ b/frontend/src/pages/admin/AnalyticsDashboard.tsx @@ -220,14 +220,21 @@ export default function AnalyticsDashboard() { setProductSns([]); }; - // ─── 柱状图:个人能力图谱(X=设备身份证,系列=人员,并排对比) ── + // ─── 柱状图:人员视图(X=设备身份证,系列=人员,居中紧凑,消除幽灵占位) ── const capabilityOption = useMemo(() => { const categories = capability?.categories ?? []; const devices = capability?.devices ?? []; + // 每台设备上真正产生耗时的人员 seriesIndex 数组 + const activePerDevice = devices.map((_, devIdx) => + (capability?.series ?? []) + .map((s, sIdx) => (s.data[devIdx] && s.data[devIdx].value != null ? sIdx : -1)) + .filter((idx) => idx !== -1), + ); return { tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, + appendToBody: true, formatter: (p: any) => { const items = Array.isArray(p) ? p : [p]; const idx = items[0]?.dataIndex ?? 0; @@ -245,7 +252,7 @@ export default function AnalyticsDashboard() { const done = d.last_completed_at ? `
结束时间:${dayjs(d.last_completed_at).format("MM-DD HH:mm")}` : ""; - return `${it.marker}${it.seriesName}:耗时 ${d.value ?? 0} 小时(${st})${recv}${done}`; + return `${it.marker}${it.seriesName}:耗时 ${d.actualValue ?? 0} 小时(${st})${recv}${done}`; }) .join("
"); return ( @@ -256,37 +263,61 @@ export default function AnalyticsDashboard() { }, }, legend: { top: 0, type: "scroll" }, - grid: { left: 48, right: 24, top: 40, bottom: 72 }, + grid: { left: 48, right: 24, top: 60, bottom: 96 }, 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 }, + data: devices.map((d) => + [d.material_name, d.spec_model, d.external_serial, d.product_sn].filter(Boolean).join("\n"), + ), + axisLabel: { fontSize: 10, interval: 0, lineHeight: 13 }, }, yAxis: { type: "value", name: "耗时(小时)" }, - series: (capability?.series ?? []).map((s) => ({ + series: (capability?.series ?? []).map((s, sIdx) => ({ name: s.name, - type: "bar" as const, - barMaxWidth: 24, - barMinHeight: 3, + type: "custom", + encode: { x: 0, y: 1 }, label: { show: true, position: "top", - formatter: (p: any) => (p.value == null ? "" : `${p.value}h`), + distance: 6, + formatter: (p: any) => (p.data?.actualValue == null ? "" : `${p.data.actualValue}h`), }, - markLine: { data: [{ type: "average", name: "平均耗时" }] }, - data: s.data.map((d, i) => - d.value == null - ? null - : { - value: d.value, - spec_model: d.spec_model, - status: d.status, - first_received_at: d.first_received_at, - last_completed_at: d.last_completed_at, - product_sn: categories[i], - external_serial: devices[i]?.external_serial, - }, - ), + renderItem: (params: any, api: any) => { + const devIdx = params.dataIndex; + const val = api.value(1); + if (val == null || isNaN(val)) return; + + const activeSeries = activePerDevice[devIdx]; + const localIndex = activeSeries.indexOf(sIdx); + if (localIndex === -1) return; + + const barWidth = 24; // 黄金粗细,绝不妥协 + const gap = 6; // 紧凑的柱间距 + const totalWidth = activeSeries.length * barWidth + (activeSeries.length - 1) * gap; + + // 核心:彻底消除幽灵占位,让存活的柱子绝对居中对齐 + const centerX = api.coord([devIdx, 0])[0]; + const x = centerX - totalWidth / 2 + localIndex * (barWidth + gap); + + const valY = api.coord([devIdx, val])[1]; + const y0 = api.coord([devIdx, 0])[1]; + const height = Math.max(y0 - valY, 3); // 至少 3px,0 值/极小值也有柱子 + const y = y0 - height; + + return { + type: "rect", + shape: { x, y, width: barWidth, height, r: [3, 3, 0, 0] }, + style: api.style(), + }; + }, + data: s.data.map((d, i) => { + if (d.value == null) return null; + return { + ...d, + value: [i, d.value], // custom 必须的 [x, y] 坐标格式 + actualValue: d.value, // 供 tooltip 读取的真实值 + }; + }), })), }; }, [capability]); @@ -392,10 +423,12 @@ export default function AnalyticsDashboard() { }, }, legend: { top: 0, type: "scroll", data: series.map((s) => s.name) }, - grid: { left: 48, right: 24, top: 60, bottom: 72 }, + grid: { left: 48, right: 24, top: 60, bottom: 96 }, xAxis: { type: "category", - data: devices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)), + data: devices.map((d) => + [d.material_name, d.spec_model, d.external_serial, d.product_sn].filter(Boolean).join("\n"), + ), axisLabel: { fontSize: 11, interval: 0, color: '#666', lineHeight: 14 }, axisLine: { lineStyle: { color: '#ddd' } }, axisTick: { show: false } @@ -667,11 +700,15 @@ export default function AnalyticsDashboard() { /> ) : ( - +
+
+ +
+
)} ),