diff --git a/frontend/src/pages/admin/AnalyticsDashboard.tsx b/frontend/src/pages/admin/AnalyticsDashboard.tsx index e34fbb3..ec50d2d 100644 --- a/frontend/src/pages/admin/AnalyticsDashboard.tsx +++ b/frontend/src/pages/admin/AnalyticsDashboard.tsx @@ -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("today"); + const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null); const [assigneeIds, setAssigneeIds] = useState(() => (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("
"); return ( - `设备身份证:${sn}` + + `产品名称:${dev?.material_name ?? "—"}` + + `
设备身份证:${sn}` + (ext ? `
产品序列号:${ext}` : "") + `
规格型号:${dev?.spec_model ?? "—"}
${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(() => { - 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 = {}; - 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 = {}; 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 = `
${device.spec_model || "未知型号"}
`; + let html = `
${device.material_name || "未知产品"}(${device.spec_model || "未知型号"})
`; html += `
身份证: ${device.product_sn}
`; html += `
`; if (device.started_at) { html += `
开始时间:${device.started_at}
`; } - html += `
实际流转周期:${stats.leadTime} h
`; + html += `
实际流转周期:${stats.leadTime.toFixed(1)} h
`; html += `
累计投入工时:${stats.totalInputHours.toFixed(1)} h
`; html += `
中间空闲时长:${stats.idleTime.toFixed(1)} h
`; @@ -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() { {/* 顶部统一筛选栏 */}
- { 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 ( -
- {day.date()} - {hasDot && } -
- ); - }} - style={{ width: 240 }} - placeholder={["开始日期", "结束日期"]} - /> + optionType="button" + buttonStyle="solid" + > + 今天 + 近7天 + 近30天 + 自定义 + + {dateKey === "custom" && ( + 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 ( +
+ {day.date()} + {hasDot && } +
+ ); + }} + style={{ width: 240 }} + placeholder={["开始日期", "结束日期"]} + /> + )}