feat(analytics): 筛选器体验升级(人名网格化/规格物料名/设备卡片下拉)

- 人员选择器 4 列网格 + 两字姓名对齐 + 全选/全不选
- 规格型号 2 列网格 + 物料名展示 + 全选/全不选
- 设备输入框改为卡片式下拉器(物料名/S/N/完整身份证)
- productSn(string) 改为 productSns(string[]) 多选
This commit is contained in:
2026-08-14 14:30:43 +08:00
parent 807df56c3a
commit 31d9a8781a

View File

@ -2,7 +2,7 @@
* 个人能力图谱X 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */ * 个人能力图谱X 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom"; 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 { Users, GitBranch, RefreshCw, Loader2, AlertCircle } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs"; import dayjs, { type Dayjs } from "dayjs";
import type { EChartsCoreOption } from "echarts/core"; import type { EChartsCoreOption } from "echarts/core";
@ -54,6 +54,11 @@ function imageUrl(u: string) {
return base + path; return base + path;
} }
// ─── 中文姓名对齐:两字名字中间插入全角空格,与三字名视觉对齐 ──
function alignName(name: string) {
return name.length === 2 ? name[0] + "" + name[1] : name;
}
export default function AnalyticsDashboard() { export default function AnalyticsDashboard() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -65,13 +70,14 @@ export default function AnalyticsDashboard() {
const [specModels, setSpecModels] = useState<string[]>(() => const [specModels, setSpecModels] = useState<string[]>(() =>
(searchParams.get("spec_models") || searchParams.get("spec_model") || "").split(",").filter(Boolean), (searchParams.get("spec_models") || searchParams.get("spec_model") || "").split(",").filter(Boolean),
); );
const [productSn, setProductSn] = useState(() => const [productSns, setProductSns] = useState<string[]>(() =>
searchParams.get("product_sn") || searchParams.get("sn") || "", (searchParams.get("product_sns") || searchParams.get("product_sn") || searchParams.get("sn") || "")
.split(",").filter(Boolean),
); );
const [activeTab, setActiveTab] = useState("capability"); const [activeTab, setActiveTab] = useState("capability");
// ─── 下拉选项 + 数据 ───────────────────────────────────── // ─── 下拉选项 + 数据 ─────────────────────────────────────
const [options, setOptions] = useState<AnalyticsOptions>({ assignees: [], spec_models: [] }); const [options, setOptions] = useState<AnalyticsOptions>({ assignees: [], spec_models: [], devices: [] });
const [capability, setCapability] = useState<CapabilityResponse | null>(null); const [capability, setCapability] = useState<CapabilityResponse | null>(null);
const [capabilityLoading, setCapabilityLoading] = useState(false); const [capabilityLoading, setCapabilityLoading] = useState(false);
const [flow, setFlow] = useState<FlowResponse | null>(null); const [flow, setFlow] = useState<FlowResponse | null>(null);
@ -93,9 +99,9 @@ export default function AnalyticsDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (assigneeIds.length) params.set("assignee_id", assigneeIds.join(",")); if (assigneeIds.length) params.set("assignee_id", assigneeIds.join(","));
if (specModels.length) params.set("spec_models", specModels.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 }); setSearchParams(params, { replace: true });
}, [assigneeIds, specModels, productSn, setSearchParams]); }, [assigneeIds, specModels, productSns, setSearchParams]);
// ─── 构建查询 ──────────────────────────────────────────── // ─── 构建查询 ────────────────────────────────────────────
const buildCapabilityQuery = useCallback((): CapabilityQuery => { const buildCapabilityQuery = useCallback((): CapabilityQuery => {
@ -111,11 +117,10 @@ export default function AnalyticsDashboard() {
const buildFlowQuery = useCallback((): FlowQuery => { const buildFlowQuery = useCallback((): FlowQuery => {
const q: FlowQuery = {}; const q: FlowQuery = {};
const sns = productSn.split(/[,\s]+/).filter(Boolean); if (productSns.length) q.product_sns = productSns;
if (sns.length) q.product_sns = sns;
if (specModels.length) q.spec_models = specModels; if (specModels.length) q.spec_models = specModels;
return q; return q;
}, [productSn, specModels]); }, [productSns, specModels]);
// ─── 能力图谱(需选择人员才发起) ── // ─── 能力图谱(需选择人员才发起) ──
const loadCapability = useCallback(async () => { const loadCapability = useCallback(async () => {
@ -135,7 +140,7 @@ export default function AnalyticsDashboard() {
// ─── 流转对比(需选型号或输入身份证才发起) ── // ─── 流转对比(需选型号或输入身份证才发起) ──
const loadFlow = useCallback(async () => { const loadFlow = useCallback(async () => {
if (specModels.length === 0 && productSn.trim().length === 0) { if (specModels.length === 0 && productSns.length === 0) {
setFlow(null); setFlow(null);
return; return;
} }
@ -147,7 +152,7 @@ export default function AnalyticsDashboard() {
} finally { } finally {
setFlowLoading(false); setFlowLoading(false);
} }
}, [specModels, productSn, buildFlowQuery]); }, [specModels, productSns, buildFlowQuery]);
// ─── 下拉选项(动态联动:随 assigneeIds/specModels 变化重新拉取) ── // ─── 下拉选项(动态联动:随 assigneeIds/specModels 变化重新拉取) ──
useEffect(() => { useEffect(() => {
@ -206,7 +211,7 @@ export default function AnalyticsDashboard() {
setRange(null); setRange(null);
setAssigneeIds([]); setAssigneeIds([]);
setSpecModels([]); setSpecModels([]);
setProductSn(""); setProductSns([]);
}; };
// ─── 柱状图个人能力图谱X=设备身份证,系列=人员,并排对比) ── // ─── 柱状图个人能力图谱X=设备身份证,系列=人员,并排对比) ──
@ -505,10 +510,35 @@ export default function AnalyticsDashboard() {
placeholder="人员(可多选)" placeholder="人员(可多选)"
value={assigneeIds} value={assigneeIds}
onChange={setAssigneeIds} 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 }} style={{ minWidth: 160, maxWidth: 240 }}
maxTagCount={1} maxTagCount="responsive"
maxTagTextLength={4} popupMatchSelectWidth={false}
dropdownRender={() => (
<div className="w-[400px] p-3">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-gray-400"> {options.assignees.length} </span>
<div className="flex items-center gap-1">
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setAssigneeIds(options.assignees.map((a) => a.id)); }}></Button>
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setAssigneeIds([]); }}></Button>
</div>
</div>
<div className="grid grid-cols-4 gap-2">
{options.assignees.map((a) => {
const checked = assigneeIds.includes(a.id);
return (
<div
key={a.id}
onClick={() => 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)}
</div>
);
})}
</div>
</div>
)}
/> />
<Select <Select
mode="multiple" mode="multiple"
@ -517,18 +547,74 @@ export default function AnalyticsDashboard() {
placeholder="规格型号" placeholder="规格型号"
value={specModels} value={specModels}
onChange={setSpecModels} onChange={setSpecModels}
options={options.spec_models.map((s) => ({ value: s, label: s }))} options={options.spec_models.map((s) => ({
value: s.spec_model,
label: s.material_name ? `${s.material_name} (${s.spec_model})` : s.spec_model,
}))}
style={{ minWidth: 160, maxWidth: 240 }} style={{ minWidth: 160, maxWidth: 240 }}
maxTagCount={1} maxTagCount="responsive"
maxTagTextLength={8} popupMatchSelectWidth={false}
dropdownRender={() => (
<div className="w-[460px] p-3">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-gray-400"> {options.spec_models.length} </span>
<div className="flex items-center gap-1">
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setSpecModels(options.spec_models.map((s) => s.spec_model)); }}></Button>
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setSpecModels([]); }}></Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{options.spec_models.map((s) => {
const checked = specModels.includes(s.spec_model);
return (
<div
key={s.spec_model}
onClick={() => setSpecModels((prev) => (checked ? prev.filter((m) => m !== s.spec_model) : [...prev, s.spec_model]))}
className={`cursor-pointer rounded-md px-2 py-1.5 transition-colors ${checked ? "bg-blue-50 font-medium text-blue-700" : "bg-gray-50 text-gray-600 hover:bg-gray-100"}`}
>
<div className="truncate text-sm">{s.material_name || "未知产品"}</div>
<div className="truncate text-xs text-gray-400">{s.spec_model}</div>
</div>
);
})}
</div>
</div>
)}
/> />
<Input <Select
size="small" mode="multiple"
showSearch
allowClear allowClear
placeholder="设备身份证(可多个,逗号分隔)" size="small"
value={productSn} placeholder="搜索或选择设备..."
onChange={(e) => setProductSn(e.target.value)} value={productSns}
style={{ width: 200 }} 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 (
<div className="flex flex-col border-b border-gray-50 py-2">
<div className="text-sm font-bold text-gray-800">
{d.material_name || "未知产品"}{" "}
<span className="text-xs font-normal text-gray-500">({d.spec_model || "无型号"})</span>
</div>
<div className="mt-1 flex items-center justify-between text-xs">
<span className="text-gray-400">
S/N: <span className="text-gray-600">{d.external_serial || "无"}</span>
</span>
<span className="font-mono text-xs text-gray-500">ID: {d.product_sn}</span>
</div>
</div>
);
}}
/> />
<Button size="small" type="primary" onClick={() => { loadCapability(); loadFlow(); }}> <Button size="small" type="primary" onClick={() => { loadCapability(); loadFlow(); }}>
@ -601,7 +687,7 @@ export default function AnalyticsDashboard() {
) : flowEmpty ? ( ) : flowEmpty ? (
<div className="flex h-[380px] items-center justify-center"> <div className="flex h-[380px] items-center justify-center">
<Empty <Empty
description={specModels.length === 0 && productSn.trim().length === 0 ? "请先选择规格型号或输入设备身份证查看流转" : "暂无数据"} description={specModels.length === 0 && productSns.length === 0 ? "请先选择规格型号或输入设备身份证查看流转" : "暂无数据"}
/> />
</div> </div>
) : ( ) : (