From 31d9a8781a3bb84ef97261e7d0b47af1b0e30390 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Fri, 14 Aug 2026 14:30:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E7=AD=9B=E9=80=89=E5=99=A8?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E5=8D=87=E7=BA=A7=EF=BC=88=E4=BA=BA=E5=90=8D?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E5=8C=96/=E8=A7=84=E6=A0=BC=E7=89=A9?= =?UTF-8?q?=E6=96=99=E5=90=8D/=E8=AE=BE=E5=A4=87=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 人员选择器 4 列网格 + 两字姓名对齐 + 全选/全不选 - 规格型号 2 列网格 + 物料名展示 + 全选/全不选 - 设备输入框改为卡片式下拉器(物料名/S/N/完整身份证) - productSn(string) 改为 productSns(string[]) 多选 --- .../src/pages/admin/AnalyticsDashboard.tsx | 136 ++++++++++++++---- 1 file changed, 111 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/admin/AnalyticsDashboard.tsx b/frontend/src/pages/admin/AnalyticsDashboard.tsx index 8258c1a..6a8cd23 100644 --- a/frontend/src/pages/admin/AnalyticsDashboard.tsx +++ b/frontend/src/pages/admin/AnalyticsDashboard.tsx @@ -2,7 +2,7 @@ * 个人能力图谱:X 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { DatePicker, Tabs, Select, Input, Button, Empty, Modal, Timeline, Image } from "antd"; +import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image } from "antd"; import { Users, GitBranch, RefreshCw, Loader2, AlertCircle } from "lucide-react"; import dayjs, { type Dayjs } from "dayjs"; import type { EChartsCoreOption } from "echarts/core"; @@ -54,6 +54,11 @@ function imageUrl(u: string) { return base + path; } +// ─── 中文姓名对齐:两字名字中间插入全角空格,与三字名视觉对齐 ── +function alignName(name: string) { + return name.length === 2 ? name[0] + " " + name[1] : name; +} + export default function AnalyticsDashboard() { const [searchParams, setSearchParams] = useSearchParams(); @@ -65,13 +70,14 @@ export default function AnalyticsDashboard() { const [specModels, setSpecModels] = useState(() => (searchParams.get("spec_models") || searchParams.get("spec_model") || "").split(",").filter(Boolean), ); - const [productSn, setProductSn] = useState(() => - searchParams.get("product_sn") || searchParams.get("sn") || "", + const [productSns, setProductSns] = useState(() => + (searchParams.get("product_sns") || searchParams.get("product_sn") || searchParams.get("sn") || "") + .split(",").filter(Boolean), ); const [activeTab, setActiveTab] = useState("capability"); // ─── 下拉选项 + 数据 ───────────────────────────────────── - const [options, setOptions] = useState({ assignees: [], spec_models: [] }); + const [options, setOptions] = useState({ assignees: [], spec_models: [], devices: [] }); const [capability, setCapability] = useState(null); const [capabilityLoading, setCapabilityLoading] = useState(false); const [flow, setFlow] = useState(null); @@ -93,9 +99,9 @@ export default function AnalyticsDashboard() { const params = new URLSearchParams(); if (assigneeIds.length) params.set("assignee_id", assigneeIds.join(",")); if (specModels.length) params.set("spec_models", specModels.join(",")); - if (productSn.trim()) params.set("product_sn", productSn.trim()); + if (productSns.length) params.set("product_sns", productSns.join(",")); setSearchParams(params, { replace: true }); - }, [assigneeIds, specModels, productSn, setSearchParams]); + }, [assigneeIds, specModels, productSns, setSearchParams]); // ─── 构建查询 ──────────────────────────────────────────── const buildCapabilityQuery = useCallback((): CapabilityQuery => { @@ -111,11 +117,10 @@ export default function AnalyticsDashboard() { const buildFlowQuery = useCallback((): FlowQuery => { const q: FlowQuery = {}; - const sns = productSn.split(/[,,\s]+/).filter(Boolean); - if (sns.length) q.product_sns = sns; + if (productSns.length) q.product_sns = productSns; if (specModels.length) q.spec_models = specModels; return q; - }, [productSn, specModels]); + }, [productSns, specModels]); // ─── 能力图谱(需选择人员才发起) ── const loadCapability = useCallback(async () => { @@ -135,7 +140,7 @@ export default function AnalyticsDashboard() { // ─── 流转对比(需选型号或输入身份证才发起) ── const loadFlow = useCallback(async () => { - if (specModels.length === 0 && productSn.trim().length === 0) { + if (specModels.length === 0 && productSns.length === 0) { setFlow(null); return; } @@ -147,7 +152,7 @@ export default function AnalyticsDashboard() { } finally { setFlowLoading(false); } - }, [specModels, productSn, buildFlowQuery]); + }, [specModels, productSns, buildFlowQuery]); // ─── 下拉选项(动态联动:随 assigneeIds/specModels 变化重新拉取) ── useEffect(() => { @@ -206,7 +211,7 @@ export default function AnalyticsDashboard() { setRange(null); setAssigneeIds([]); setSpecModels([]); - setProductSn(""); + setProductSns([]); }; // ─── 柱状图:个人能力图谱(X=设备身份证,系列=人员,并排对比) ── @@ -505,10 +510,35 @@ export default function AnalyticsDashboard() { placeholder="人员(可多选)" value={assigneeIds} onChange={setAssigneeIds} - options={options.assignees.map((a) => ({ value: a.id, label: `${a.name} (${a.id})` }))} + options={options.assignees.map((a) => ({ value: a.id, label: alignName(a.name) }))} style={{ minWidth: 160, maxWidth: 240 }} - maxTagCount={1} - maxTagTextLength={4} + maxTagCount="responsive" + popupMatchSelectWidth={false} + dropdownRender={() => ( +
+
+ 共 {options.assignees.length} 人 +
+ + +
+
+
+ {options.assignees.map((a) => { + const checked = assigneeIds.includes(a.id); + return ( +
setAssigneeIds((prev) => (checked ? prev.filter((id) => id !== a.id) : [...prev, a.id]))} + className={`cursor-pointer rounded-md px-2 py-1.5 text-center text-sm transition-colors ${checked ? "bg-blue-50 font-medium text-blue-700" : "bg-gray-50 text-gray-600 hover:bg-gray-100"}`} + > + {alignName(a.name)} +
+ ); + })} +
+
+ )} /> setProductSn(e.target.value)} - style={{ width: 200 }} + size="small" + placeholder="搜索或选择设备..." + value={productSns} + onChange={setProductSns} + popupMatchSelectWidth={false} + style={{ minWidth: 200 }} + styles={{ popup: { root: { minWidth: 440 } } }} + options={options.devices.map((d) => ({ + value: d.product_sn, + label: d.external_serial ? `${d.external_serial} (${d.product_sn})` : d.product_sn, + data: d, + }))} + optionRender={(oriOption: any) => { + const d = oriOption.data?.data; + if (!d) return oriOption.label; + return ( +
+
+ {d.material_name || "未知产品"}{" "} + ({d.spec_model || "无型号"}) +
+
+ + S/N: {d.external_serial || "无"} + + ID: {d.product_sn} +
+
+ ); + }} />