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 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */
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<string[]>(() =>
(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<string[]>(() =>
(searchParams.get("product_sns") || searchParams.get("product_sn") || searchParams.get("sn") || "")
.split(",").filter(Boolean),
);
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 [capabilityLoading, setCapabilityLoading] = useState(false);
const [flow, setFlow] = useState<FlowResponse | null>(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={() => (
<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
mode="multiple"
@ -517,18 +547,74 @@ export default function AnalyticsDashboard() {
placeholder="规格型号"
value={specModels}
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 }}
maxTagCount={1}
maxTagTextLength={8}
maxTagCount="responsive"
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
size="small"
<Select
mode="multiple"
showSearch
allowClear
placeholder="设备身份证(可多个,逗号分隔)"
value={productSn}
onChange={(e) => 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 (
<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(); }}>
@ -601,7 +687,7 @@ export default function AnalyticsDashboard() {
) : flowEmpty ? (
<div className="flex h-[380px] items-center justify-center">
<Empty
description={specModels.length === 0 && productSn.trim().length === 0 ? "请先选择规格型号或输入设备身份证查看流转" : "暂无数据"}
description={specModels.length === 0 && productSns.length === 0 ? "请先选择规格型号或输入设备身份证查看流转" : "暂无数据"}
/>
</div>
) : (