feat: 效能分析X轴四行标签/flow分块/自然工作日即时切换/时间快捷选项

This commit is contained in:
2026-09-02 10:39:01 +08:00
parent 3c6c8c0c53
commit 06337fc8eb
2 changed files with 182 additions and 73 deletions

View File

@ -12,7 +12,7 @@ import BaseEChart from "../../components/BaseEChart";
import {
fetchCapabilityProfile, fetchFlowData, fetchAnalyticsOptions, fetchDeviceRecords,
type CapabilityResponse, type FlowResponse, type AnalyticsOptions,
type CapabilityQuery, type FlowQuery, type DeviceRecord,
type CapabilityQuery, type FlowQuery, type DeviceRecord, type FlowDevice, type FlowInterval,
} from "../../services/analyticsApi";
dayjs.locale("zh-cn");
@ -66,12 +66,29 @@ function alignName(name: string) {
}
const CHUNK_SIZE = 6; // 人员视图瀑布流每块设备数
const FLOW_CHUNK_SIZE = 8; // 轨迹流转瀑布流每块设备数
// ⏰ 时间筛选(今天 / 近7天 / 近30天 / 自定义)
type DateRangeKey = "today" | "7d" | "30d" | "custom";
function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
if (key === "custom" && customRange) {
return {
since: customRange[0].startOf("day").toISOString(),
until: customRange[1].endOf("day").toISOString(),
};
}
const since = dayjs().startOf("day");
if (key === "7d") return { since: since.subtract(7, "day").toISOString() };
if (key === "30d") return { since: since.subtract(30, "day").toISOString() };
return { since: since.toISOString() };
}
export default function AnalyticsDashboard() {
const [searchParams, setSearchParams] = useSearchParams();
// ─── 筛选状态(从 URL 懒初始化) ─────────────────────────
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
const [assigneeIds, setAssigneeIds] = useState<string[]>(() =>
(searchParams.get("assignee_id") || "").split(",").filter(Boolean),
);
@ -124,21 +141,23 @@ export default function AnalyticsDashboard() {
const q: CapabilityQuery = {};
if (assigneeIds.length) q.assignee_ids = assigneeIds;
if (specModels.length) q.spec_models = specModels;
if (range) {
q.since = range[0].startOf("day").toISOString();
q.until = range[1].endOf("day").toISOString();
}
const { since, until } = rangeToParams(dateKey, customRange);
if (since) q.since = since;
if (until) q.until = until;
q.mode = totalDaysMode; // 自然天 / 工作日,控制人员视图耗时口径
return q;
}, [assigneeIds, specModels, range, totalDaysMode]);
}, [assigneeIds, specModels, dateKey, customRange, totalDaysMode]);
const buildFlowQuery = useCallback((): FlowQuery => {
const q: FlowQuery = {};
if (productSns.length) q.product_sns = productSns;
if (specModels.length) q.spec_models = specModels;
q.mode = totalDaysMode; // 自然天 / 工作日,控制轨迹流转图时间口径
const { since, until } = rangeToParams(dateKey, customRange);
if (since) q.since = since;
if (until) q.until = until;
// flow 后端同时返回自然/工作日两套数据,切换由前端 isWorkday 即时映射,无需重新请求
return q;
}, [productSns, specModels, totalDaysMode]);
}, [productSns, specModels, dateKey, customRange]);
// ─── 能力图谱(需选择人员才发起) ──
const loadCapability = useCallback(async () => {
@ -216,18 +235,20 @@ export default function AnalyticsDashboard() {
if (sn) setRecordQuery({ sn, all: false });
}, []);
// 流转对比:点柱子/背景 → 展示该设备全生命周期所有备注(不过滤人员)
const handleFlowZrClick = useCallback((chart: any, e: any) => {
// 流转对比:点柱子/背景 → 展示该设备全生命周期所有备注(不过滤人员);分块渲染需换算全局下标
const handleFlowZrClick = useCallback((chart: any, e: any, chunkStart: number) => {
const coord = chart.convertFromPixel({ seriesIndex: 0 }, [e.offsetX, e.offsetY]);
if (!coord || coord.length < 1) return;
const dataIndex = Math.round(coord[0]);
if (dataIndex < 0 || dataIndex >= flowSnRef.current.length) return;
const sn = flowSnRef.current[dataIndex];
const globalIdx = chunkStart + dataIndex;
if (globalIdx < 0 || globalIdx >= flowSnRef.current.length) return;
const sn = flowSnRef.current[globalIdx];
if (sn) setRecordQuery({ sn, all: true });
}, []);
const handleReset = () => {
setRange(null);
setDateKey("today");
setCustomRange(null);
setAssigneeIds([]);
setSpecModels([]);
setProductSns([]);
@ -286,7 +307,8 @@ export default function AnalyticsDashboard() {
})
.join("<br/>");
return (
`设备身份证${sn}` +
`产品名称${dev?.material_name ?? "—"}` +
`<br/>设备身份证:${sn}` +
(ext ? `<br/>产品序列号:${ext}` : "") +
`<br/>规格型号:${dev?.spec_model ?? "—"}<br/>${rows}`
);
@ -296,8 +318,16 @@ export default function AnalyticsDashboard() {
grid: { left: 48, right: 24, top: 80, bottom: 48 }, // top 加大,避免图例遮挡柱子
xAxis: {
type: "category",
data: chunkDevices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
axisLabel: { fontSize: 11, interval: 0, color: "#666", lineHeight: 14 },
data: chunkDevices.map((d) => d.product_sn),
axisLabel: {
fontSize: 11, interval: 0, color: "#666", lineHeight: 14,
// 🔧 标签改为「产品名称(截断) + 尾号4位」避免 16 位身份证重叠
formatter: (sn: string) => {
const dev = chunkDevices.find((d) => d.product_sn === sn);
const name = dev?.material_name ? dev.material_name.slice(0, 6) : sn.slice(0, 6);
return `${name} ${sn.slice(-4)}`;
},
},
},
yAxis: { type: "value", name: "耗时(小时)" },
series: chunkSeries.map((s, sIdx) => ({
@ -354,30 +384,54 @@ export default function AnalyticsDashboard() {
return set;
}, [capability]);
// ─── 生命周期时间轴(主干延续 + 分支贴两侧 + 上帝视角 Tooltip ──
const flowOption = useMemo<EChartsCoreOption>(() => {
const devices = flow?.devices ?? [];
const series = flow?.series ?? [];
// ─── 轨迹流转:按设备分块(每块 FLOW_CHUNK_SIZE 台),每块一个 ECharts 实例 ──
const flowChunks = useMemo(() => {
if (!flow) return [];
const chunks: { devices: FlowDevice[]; series: { name: string; data: FlowInterval[] }[]; startIndex: number }[] = [];
for (let i = 0; i < flow.devices.length; i += FLOW_CHUNK_SIZE) {
const devSlice = flow.devices.slice(i, i + FLOW_CHUNK_SIZE);
const idSet = new Set(devSlice.map((_, j) => i + j));
const seriesSlice = flow.series
.map((s) => ({
name: s.name,
data: s.data
.filter((it) => idSet.has(it[0]))
.map((it) => [it[0] - i, ...it.slice(1)] as FlowInterval),
}))
.filter((s) => s.data.length > 0);
chunks.push({ devices: devSlice, series: seriesSlice, startIndex: i });
}
return chunks;
}, [flow]);
// ─── 单个块的 ECharts Option 工厂isWorkday 切换自然/工作日偏移,即时重绘) ──
const getFlowChunkOption = (
chunkDevices: FlowDevice[],
chunkSeries: { name: string; data: FlowInterval[] }[],
isWorkday: boolean,
): EChartsCoreOption => {
// 分支配色(与人员一一对应,用于柱子和 tooltip 色点)
const BRANCH_COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16"];
const colorMap: Record<string, string> = {};
series.forEach((s, index) => {
chunkSeries.forEach((s, index) => {
colorMap[s.name] = BRANCH_COLORS[index % BRANCH_COLORS.length];
});
// 1. 预计算每台设备的统计:累计投入、空闲时长、人员排序
const deviceStats = devices.map((d, idx) => {
// 1. 预计算每台设备的统计:累计投入、空闲时长、人员排序(按 isWorkday 选偏移/时长)
const deviceStats = chunkDevices.map((d, idx) => {
let totalInputHours = 0;
const personHours: Record<string, number> = {};
const intervals: [number, number][] = [];
series.forEach((s) => {
s.data.forEach((task: any) => {
chunkSeries.forEach((s) => {
s.data.forEach((task) => {
if (task[0] === idx) {
totalInputHours += task[4];
personHours[s.name] = (personHours[s.name] || 0) + task[4];
intervals.push([task[1], task[2]]);
const dur = isWorkday ? task[8] : task[7];
const so = isWorkday ? task[5] : task[3];
const eo = isWorkday ? task[6] : task[4];
totalInputHours += dur;
personHours[s.name] = (personHours[s.name] || 0) + dur;
intervals.push([so, eo]);
}
});
});
@ -395,36 +449,48 @@ export default function AnalyticsDashboard() {
mergedEnd = e;
}
}
const idleTime = Math.max((d.lead_time || 0) - covered, 0);
const leadMax = intervals.reduce((m, it) => Math.max(m, it[1]), 0);
const idleTime = Math.max(leadMax - covered, 0);
const sortedPeople = Object.entries(personHours).sort((a, b) => b[1] - a[1]);
return {
leadTime: d.lead_time || 0,
leadTime: leadMax,
totalInputHours,
idleTime,
sortedPeople,
};
});
// 背景主干高度:所选模式下的总生命周期(严丝合缝,消除自然空闲断层)
const leadByMode = chunkDevices.map((d, idx) => {
let maxEnd = 0;
chunkSeries.forEach((s) => s.data.forEach((t) => {
if (t[0] === idx) {
const e = isWorkday ? t[6] : t[4];
if (e > maxEnd) maxEnd = e;
}
}));
return Math.max(maxEnd, 1);
});
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow", shadowStyle: { color: "rgba(0,0,0,0.05)" } },
formatter: (params: any) => {
const deviceIndex = params[0].dataIndex;
const device = devices[deviceIndex];
const device = chunkDevices[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>`;
let html = `<div style="font-size:14px;font-weight:bold;color:#1f2937;margin-bottom:4px;">${device.material_name || "未知产品"}${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:#111827">${stats.leadTime.toFixed(1)} 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>`;
@ -442,14 +508,24 @@ export default function AnalyticsDashboard() {
return html;
},
},
legend: { top: 0, type: "scroll", data: series.map((s) => s.name) },
legend: { top: 0, type: "scroll", data: chunkSeries.map((s) => s.name) },
grid: { left: 48, right: 24, top: 60, bottom: 96 },
xAxis: {
type: "category",
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 },
data: chunkDevices.map((d) => d.product_sn),
axisLabel: {
fontSize: 11, interval: 0, color: '#666', lineHeight: 15, width: 120, overflow: 'break' as const,
// 🔧 X 轴四行展示:产品名称 / 规格型号 / 业务序列号(若有) / 身份证
formatter: (sn: string) => {
const dev = chunkDevices.find((d) => d.product_sn === sn);
if (!dev) return sn;
const lines = [dev.material_name || "—"];
if (dev.spec_model) lines.push(dev.spec_model);
if (dev.external_serial) lines.push(dev.external_serial);
lines.push(dev.product_sn);
return lines.join("\n");
},
},
axisLine: { lineStyle: { color: '#ddd' } },
axisTick: { show: false }
},
@ -461,7 +537,7 @@ export default function AnalyticsDashboard() {
splitLine: { lineStyle: { type: 'dashed', color: '#f3f4f6' } }
},
series: [
// 1. 背景主干 (The Trunk) - 连贯的浅灰底
// 1. 背景主干 (The Trunk) - 连贯的浅灰底(所选模式生命周期)
{
name: "总生命周期",
type: "bar" as const,
@ -477,24 +553,25 @@ export default function AnalyticsDashboard() {
value: { fontSize: 14, fontWeight: "bold", color: "#1f2937" },
},
},
data: devices.map((d) => d.lead_time || 0),
data: leadByMode,
},
// 2. 任务分支 (The Branches) - 贴主干两侧
...series.map((s, index) => ({
// 2. 任务分支 (The Branches) - 贴主干两侧(按模式偏移,紧凑贴合)
...chunkSeries.map((s, index) => ({
name: s.name,
type: "custom" as const,
z: 2,
itemStyle: { color: BRANCH_COLORS[index % BRANCH_COLORS.length] },
dimensions: ['device', 'start', 'end', 'task', 'duration', 'is_main'],
encode: { x: 0, y: [1, 2] },
dimensions: ['device', 'task', 'is_main', 'start_nat', 'end_nat', 'start_work', 'end_work', 'dur_total', 'dur_work'],
encode: { x: 0, y: [3, 4] },
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 rawData = s.data[params.dataIndex]; // [idx, task, is_main, start_nat, end_nat, start_work, end_work, dur_total, dur_work]
const startOff = isWorkday ? rawData[5] : rawData[3];
const endOff = isWorkday ? rawData[6] : rawData[4];
const isMain = rawData[2] === 1;
const start = api.coord([categoryIndex, startOff]);
const end = api.coord([categoryIndex, endOff]);
const trunkWidth = 32; // 灰色背景主干的宽度
const blockWidth = 20;
@ -526,7 +603,7 @@ export default function AnalyticsDashboard() {
})),
],
};
}, [flow]);
};
const capabilityEmpty = !capability || capability.series.length === 0;
const flowEmpty = !flow || flow.devices.length === 0;
@ -544,25 +621,40 @@ export default function AnalyticsDashboard() {
{/* 顶部统一筛选栏 */}
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<RangePicker
{/* ⏰ 时间快捷选项:今天 / 近7天 / 近30天 / 自定义 */}
<Radio.Group
value={dateKey}
onChange={e => { setDateKey(e.target.value); setCustomRange(null); }}
size="small"
value={range as any}
onChange={(dates) => setRange(dates as [Dayjs, Dayjs] | null)}
disabledDate={(d) => d.isAfter(dayjs(), "day")}
cellRender={(current, info) => {
if (info.type !== "date") return info.originNode;
const day = current as Dayjs;
const hasDot = activityDates.has(day.format("YYYY-MM-DD"));
return (
<div className="flex flex-col items-center">
<span>{day.date()}</span>
{hasDot && <span className="h-1 w-1 rounded-full bg-blue-500" />}
</div>
);
}}
style={{ width: 240 }}
placeholder={["开始日期", "结束日期"]}
/>
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="today"></Radio.Button>
<Radio.Button value="7d">7</Radio.Button>
<Radio.Button value="30d">30</Radio.Button>
<Radio.Button value="custom"></Radio.Button>
</Radio.Group>
{dateKey === "custom" && (
<RangePicker
size="small"
value={customRange as any}
onChange={dates => setCustomRange(dates as [Dayjs, Dayjs] | null)}
disabledDate={(d) => d.isAfter(dayjs(), "day")}
cellRender={(current, info) => {
if (info.type !== "date") return info.originNode;
const day = current as Dayjs;
const hasDot = activityDates.has(day.format("YYYY-MM-DD"));
return (
<div className="flex flex-col items-center">
<span>{day.date()}</span>
{hasDot && <span className="h-1 w-1 rounded-full bg-blue-500" />}
</div>
);
}}
style={{ width: 240 }}
placeholder={["开始日期", "结束日期"]}
/>
)}
<Select
mode="multiple"
size="small"
@ -830,7 +922,19 @@ export default function AnalyticsDashboard() {
/>
</div>
) : (
<BaseEChart option={flowOption} height={380} onZrClick={handleFlowZrClick} />
/* 🔧 瀑布流:每块 FLOW_CHUNK_SIZE 台设备一个图表向下追加isWorkday 即时映射自然/工作日 */
<div className="flex flex-col gap-12 pt-4">
{flowChunks.map((chunk, idx) => (
<div key={idx} className="relative">
{idx > 0 && <div className="absolute -top-6 left-0 w-full border-t border-dashed border-gray-200" />}
<BaseEChart
option={getFlowChunkOption(chunk.devices, chunk.series, totalDaysMode === "workdays")}
height={380}
onZrClick={(chart: any, e: any) => handleFlowZrClick(chart, e, chunk.startIndex)}
/>
</div>
))}
</div>
)}
</div>
),

View File

@ -45,8 +45,9 @@ export interface FlowDevice {
total_workdays: number; // 设备生产总天数(工作日)
}
// 时间区间deviceIndex, startOffset, endOffset, taskName, duration, isMain
export type FlowInterval = [number, number, number, string, number, number];
// 时间区间deviceIndex, taskName, isMain,
// startNat, endNat, startWork, endWork, durationTotal(自然), durationWork(工作日)
export type FlowInterval = [number, string, number, number, number, number, number, number, number];
export interface FlowSeries {
name: string; // 人员姓名
@ -105,6 +106,8 @@ export interface FlowQuery {
product_sns?: string[];
spec_models?: string[];
mode?: "natural" | "workdays";
since?: string;
until?: string;
}
// ============================================================
@ -127,6 +130,8 @@ export async function fetchFlowData(query: FlowQuery): Promise<FlowResponse> {
if (query.product_sns?.length) params.product_sns = query.product_sns.join(",");
if (query.spec_models?.length) params.spec_models = query.spec_models.join(",");
if (query.mode) params.mode = query.mode;
if (query.since) params.since = query.since;
if (query.until) params.until = query.until;
const { data } = await api.get<FlowResponse>("/analytics/flow", { params });
return data;
}