feat(analytics): 人员视图重构为 custom 居中紧凑柱状 + 原生横向滚动
- capabilityOption 改 custom 系列,居中紧凑算法消除幽灵占位 - 柱宽保底 3px(0值/极小值也有柱子)、数字标签悬浮柱顶 - X 轴四行展示:物料名/规格型号/序列号/身份证 - 原生横向滚动容器(每设备 140px)+ custom-scrollbar 样式 - tooltip appendToBody 避免被容器裁剪
This commit is contained in:
@ -53,3 +53,23 @@ body {
|
|||||||
.animate-slide-in {
|
.animate-slide-in {
|
||||||
animation: slide-in 0.3s ease-out;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -220,14 +220,21 @@ export default function AnalyticsDashboard() {
|
|||||||
setProductSns([]);
|
setProductSns([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 柱状图:个人能力图谱(X=设备身份证,系列=人员,并排对比) ──
|
// ─── 柱状图:人员视图(X=设备身份证,系列=人员,居中紧凑,消除幽灵占位) ──
|
||||||
const capabilityOption = useMemo<EChartsCoreOption>(() => {
|
const capabilityOption = useMemo<EChartsCoreOption>(() => {
|
||||||
const categories = capability?.categories ?? [];
|
const categories = capability?.categories ?? [];
|
||||||
const devices = capability?.devices ?? [];
|
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 {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
axisPointer: { type: "shadow" },
|
axisPointer: { type: "shadow" },
|
||||||
|
appendToBody: true,
|
||||||
formatter: (p: any) => {
|
formatter: (p: any) => {
|
||||||
const items = Array.isArray(p) ? p : [p];
|
const items = Array.isArray(p) ? p : [p];
|
||||||
const idx = items[0]?.dataIndex ?? 0;
|
const idx = items[0]?.dataIndex ?? 0;
|
||||||
@ -245,7 +252,7 @@ export default function AnalyticsDashboard() {
|
|||||||
const done = d.last_completed_at
|
const done = d.last_completed_at
|
||||||
? `<br/>结束时间:${dayjs(d.last_completed_at).format("MM-DD HH:mm")}`
|
? `<br/>结束时间:${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("<br/>");
|
.join("<br/>");
|
||||||
return (
|
return (
|
||||||
@ -256,37 +263,61 @@ export default function AnalyticsDashboard() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { top: 0, type: "scroll" },
|
legend: { top: 0, type: "scroll" },
|
||||||
grid: { left: 48, right: 24, top: 40, bottom: 72 },
|
grid: { left: 48, right: 24, top: 60, bottom: 96 },
|
||||||
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) =>
|
||||||
axisLabel: { fontSize: 10, interval: 0 },
|
[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: "耗时(小时)" },
|
yAxis: { type: "value", name: "耗时(小时)" },
|
||||||
series: (capability?.series ?? []).map((s) => ({
|
series: (capability?.series ?? []).map((s, sIdx) => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
type: "bar" as const,
|
type: "custom",
|
||||||
barMaxWidth: 24,
|
encode: { x: 0, y: 1 },
|
||||||
barMinHeight: 3,
|
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
position: "top",
|
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: "平均耗时" }] },
|
renderItem: (params: any, api: any) => {
|
||||||
data: s.data.map((d, i) =>
|
const devIdx = params.dataIndex;
|
||||||
d.value == null
|
const val = api.value(1);
|
||||||
? null
|
if (val == null || isNaN(val)) return;
|
||||||
: {
|
|
||||||
value: d.value,
|
const activeSeries = activePerDevice[devIdx];
|
||||||
spec_model: d.spec_model,
|
const localIndex = activeSeries.indexOf(sIdx);
|
||||||
status: d.status,
|
if (localIndex === -1) return;
|
||||||
first_received_at: d.first_received_at,
|
|
||||||
last_completed_at: d.last_completed_at,
|
const barWidth = 24; // 黄金粗细,绝不妥协
|
||||||
product_sn: categories[i],
|
const gap = 6; // 紧凑的柱间距
|
||||||
external_serial: devices[i]?.external_serial,
|
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]);
|
}, [capability]);
|
||||||
@ -392,10 +423,12 @@ export default function AnalyticsDashboard() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
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: 60, bottom: 72 },
|
grid: { left: 48, right: 24, top: 60, bottom: 96 },
|
||||||
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.material_name, d.spec_model, d.external_serial, d.product_sn].filter(Boolean).join("\n"),
|
||||||
|
),
|
||||||
axisLabel: { fontSize: 11, interval: 0, color: '#666', lineHeight: 14 },
|
axisLabel: { fontSize: 11, interval: 0, color: '#666', lineHeight: 14 },
|
||||||
axisLine: { lineStyle: { color: '#ddd' } },
|
axisLine: { lineStyle: { color: '#ddd' } },
|
||||||
axisTick: { show: false }
|
axisTick: { show: false }
|
||||||
@ -667,11 +700,15 @@ export default function AnalyticsDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<BaseEChart
|
<div className="w-full overflow-x-auto pb-2 custom-scrollbar">
|
||||||
option={capabilityOption}
|
<div style={{ minWidth: (capability?.devices?.length ?? 0) * 140 }}>
|
||||||
height={420}
|
<BaseEChart
|
||||||
onZrClick={handleZrClick}
|
option={capabilityOption}
|
||||||
/>
|
height={420}
|
||||||
|
onZrClick={handleZrClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user