feat: 新增效能分析看板(个人能力图谱 + 设备人员耗时对比)

- 前端:AnalyticsDashboard 页面 + BaseEChart 封装 + analyticsApi,路由与导航
- 后端:/analytics 系列接口(capability/flow/options/device-records)
- 能力图谱单机颗粒度、流转对比按人堆叠识别瓶颈、点击下钻备注、筛选动态联动
- 依赖:引入 echarts,移除 echarts-for-react
This commit is contained in:
2026-08-14 10:19:44 +08:00
parent f4fc23be42
commit 54b5ea00f1
10 changed files with 1390 additions and 1 deletions

View File

@ -0,0 +1,580 @@
/** 效能分析看板 — 独立的 ECharts 数据可视化页面(路由 /admin/analytics)
* 个人能力图谱: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 { Users, GitBranch, RefreshCw, Loader2, AlertCircle } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs";
import type { EChartsCoreOption } from "echarts/core";
import "dayjs/locale/zh-cn";
import BaseEChart from "../../components/BaseEChart";
import {
fetchCapabilityProfile, fetchFlowData, fetchAnalyticsOptions, fetchDeviceRecords,
type CapabilityResponse, type FlowResponse, type AnalyticsOptions,
type CapabilityQuery, type FlowQuery, type DeviceRecord,
} from "../../services/analyticsApi";
dayjs.locale("zh-cn");
const { RangePicker } = DatePicker;
// ─── 状态中文映射 ─────────────────────────────────────────
const STATUS_LABEL: Record<string, string> = {
WIP: "进行中",
PENDING: "待接收",
COMPLETED: "已完成",
"—": "未涉及",
};
const STATUS_BADGE: Record<string, string> = {
WIP: "bg-blue-100 text-blue-700",
PENDING: "bg-amber-100 text-amber-700",
COMPLETED: "bg-green-100 text-green-700",
"—": "bg-gray-100 text-gray-500",
};
const STATUS_DOT: Record<string, string> = {
WIP: "blue",
PENDING: "orange",
COMPLETED: "green",
"—": "gray",
};
// ─── 图片 URL 拼接(对齐 AdminPeoplePage) ────────────────
function imageUrl(u: string) {
if (!u) return "";
if (u.startsWith("http")) return u;
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
const path = u.startsWith("/") ? u : "/" + u;
if (path.startsWith("/api/")) {
const origin = base.replace(/\/api(\/v\d+)?$/, "");
return origin + path;
}
return base + path;
}
export default function AnalyticsDashboard() {
const [searchParams, setSearchParams] = useSearchParams();
// ─── 筛选状态(从 URL 懒初始化) ─────────────────────────
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const [assigneeIds, setAssigneeIds] = useState<string[]>(() =>
(searchParams.get("assignee_id") || "").split(",").filter(Boolean),
);
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 [activeTab, setActiveTab] = useState("capability");
// ─── 下拉选项 + 数据 ─────────────────────────────────────
const [options, setOptions] = useState<AnalyticsOptions>({ assignees: [], spec_models: [] });
const [capability, setCapability] = useState<CapabilityResponse | null>(null);
const [capabilityLoading, setCapabilityLoading] = useState(false);
const [flow, setFlow] = useState<FlowResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ─── 弹窗下钻:设备备注(all=true 表示流转图点击,展示全部) ──
const [recordQuery, setRecordQuery] = useState<{ sn: string; all: boolean } | null>(null);
const [records, setRecords] = useState<DeviceRecord[]>([]);
const [recordsLoading, setRecordsLoading] = useState(false);
// ─── 筛选状态 → 同步回 URL(跳过首帧) ──
const skipSync = useRef(true);
useEffect(() => {
if (skipSync.current) {
skipSync.current = false;
return;
}
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());
setSearchParams(params, { replace: true });
}, [assigneeIds, specModels, productSn, setSearchParams]);
// ─── 构建查询 ────────────────────────────────────────────
const buildCapabilityQuery = useCallback((): CapabilityQuery => {
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();
}
return q;
}, [assigneeIds, specModels, range]);
const buildFlowQuery = useCallback((): FlowQuery => {
const q: FlowQuery = {};
const sns = productSn.split(/[,,\s]+/).filter(Boolean);
if (sns.length) q.product_sns = sns;
if (specModels.length) q.spec_models = specModels;
return q;
}, [productSn, specModels]);
// ─── 能力图谱(需选择人员才发起) ──
const loadCapability = useCallback(async () => {
if (assigneeIds.length === 0) {
setCapability(null);
return;
}
setCapabilityLoading(true);
try {
setCapability(await fetchCapabilityProfile(buildCapabilityQuery()));
} catch {
setError("加载数据失败,请确认后端已启动");
} finally {
setCapabilityLoading(false);
}
}, [assigneeIds, buildCapabilityQuery]);
// ─── 流转对比(需选型号或输入身份证才发起) ──
const loadFlow = useCallback(async () => {
if (specModels.length === 0 && productSn.trim().length === 0) {
setFlow(null);
return;
}
setFlowLoading(true);
try {
setFlow(await fetchFlowData(buildFlowQuery()));
} catch {
setError("加载数据失败,请确认后端已启动");
} finally {
setFlowLoading(false);
}
}, [specModels, productSn, buildFlowQuery]);
// ─── 下拉选项(动态联动:随 assigneeIds/specModels 变化重新拉取) ──
useEffect(() => {
fetchAnalyticsOptions(assigneeIds, specModels)
.then(setOptions)
.catch(() => {}); // 选项失败不阻塞主图表
}, [assigneeIds, specModels]);
// ─── 数据自动加载(防抖 300ms) ──
useEffect(() => {
const t = setTimeout(() => { loadCapability(); }, 300);
return () => clearTimeout(t);
}, [loadCapability]);
useEffect(() => {
const t = setTimeout(() => { loadFlow(); }, 300);
return () => clearTimeout(t);
}, [loadFlow]);
// ─── 点击柱/背景 → 换算 dataIndex → 拉取该设备备注 ──
const categoriesRef = useRef<string[]>([]);
categoriesRef.current = capability?.categories ?? [];
const flowSnRef = useRef<string[]>([]);
flowSnRef.current = (flow?.devices ?? []).map((d) => d.product_sn);
useEffect(() => {
if (!recordQuery) return;
setRecordsLoading(true);
fetchDeviceRecords(recordQuery.sn, recordQuery.all ? undefined : assigneeIds)
.then(setRecords)
.catch(() => setRecords([]))
.finally(() => setRecordsLoading(false));
}, [recordQuery, assigneeIds]);
// 能力图谱:点柱子 → 按当前人员筛选过滤备注
const handleZrClick = useCallback((chart: any, e: any) => {
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 >= categoriesRef.current.length) return;
const sn = categoriesRef.current[dataIndex];
if (sn) setRecordQuery({ sn, all: false });
}, []);
// 流转对比:点柱子/背景 → 展示该设备全生命周期所有备注(不过滤人员)
const handleFlowZrClick = useCallback((chart: any, e: any) => {
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];
if (sn) setRecordQuery({ sn, all: true });
}, []);
const handleReset = () => {
setRange(null);
setAssigneeIds([]);
setSpecModels([]);
setProductSn("");
};
// ─── 柱状图:个人能力图谱(X=设备身份证,系列=人员,并排对比) ──
const capabilityOption = useMemo<EChartsCoreOption>(() => {
const categories = capability?.categories ?? [];
const devices = capability?.devices ?? [];
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (p: any) => {
const items = Array.isArray(p) ? p : [p];
const idx = items[0]?.dataIndex ?? 0;
const sn = categories[idx] ?? "—";
const dev = devices[idx];
const ext = dev?.external_serial;
const rows = items
.filter((it: any) => (it.data?.status ?? "—") !== "—")
.map((it: any) => {
const d = it.data ?? {};
const st = STATUS_LABEL[d.status] ?? d.status;
const recv = d.first_received_at
? `<br/>接收时间:${dayjs(d.first_received_at).format("MM-DD HH:mm")}`
: "";
const done = d.last_completed_at
? `<br/>结束时间:${dayjs(d.last_completed_at).format("MM-DD HH:mm")}`
: "";
return `${it.marker}${it.seriesName}:耗时 ${d.value ?? 0} 小时(${st})${recv}${done}`;
})
.join("<br/>");
return (
`设备身份证:${sn}` +
(ext ? `<br/>产品序列号:${ext}` : "") +
`<br/>规格型号:${dev?.spec_model ?? "—"}<br/>${rows}`
);
},
},
legend: { top: 0, type: "scroll" },
grid: { left: 48, right: 24, top: 40, bottom: 72 },
xAxis: {
type: "category",
data: devices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
axisLabel: { fontSize: 10, interval: 0 },
},
yAxis: { type: "value", name: "耗时(小时)" },
series: (capability?.series ?? []).map((s) => ({
name: s.name,
type: "bar" as const,
barMaxWidth: 24,
barMinHeight: 3,
label: {
show: true,
position: "top",
formatter: (p: any) => (p.value == null ? "" : `${p.value}h`),
},
markLine: { data: [{ type: "average", name: "平均耗时" }] },
data: s.data.map((d, i) =>
d.value == null
? null
: {
value: d.value,
spec_model: d.spec_model,
status: d.status,
first_received_at: d.first_received_at,
last_completed_at: d.last_completed_at,
product_sn: categories[i],
external_serial: devices[i]?.external_serial,
},
),
})),
};
}, [capability]);
// ─── 有数据的日期集合(用于日历蓝点) ──
const activityDates = useMemo(() => {
const set = new Set<string>();
for (const s of capability?.series ?? []) {
for (const d of s.data) {
if (d.first_received_at) set.add(dayjs(d.first_received_at).format("YYYY-MM-DD"));
if (d.last_completed_at) set.add(dayjs(d.last_completed_at).format("YYYY-MM-DD"));
}
}
return set;
}, [capability]);
// ─── 柱状图:设备人员耗时对比(按人堆叠 + 柱顶总计 + tooltip 揪元凶) ──
const flowOption = useMemo<EChartsCoreOption>(() => {
const devices = flow?.devices ?? [];
const series = flow?.series ?? [];
// 每台设备总耗时(各人员累加)
const totalData = devices.map((_, i) => {
let sum = 0;
for (const s of series) {
const v = s.data[i];
if (v != null) sum += v;
}
return Math.round(sum * 10) / 10;
});
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (p: any) => {
const raw = Array.isArray(p) ? p : [p];
const idx = raw[0]?.dataIndex ?? 0;
const items = raw.filter((it: any) => it.seriesName !== "总计" && it.value != null);
const d = devices[idx];
const total = items.reduce((sum: number, it: any) => sum + it.value, 0);
const head = d
? `${d.material_name || "设备"} · ${d.spec_model || "—"}<br/>身份证:${d.product_sn}<br/>`
: "";
const body = items
.map((it: any) => {
const pct = total > 0 ? ((it.value / total) * 100).toFixed(1) : "0.0";
return `${it.marker}${it.seriesName}:${it.value} 小时(${pct}%)`;
})
.join("<br/>");
return head + body;
},
},
legend: { top: 0, type: "scroll", data: series.map((s) => s.name) },
grid: { left: 48, right: 24, top: 40, bottom: 72 },
xAxis: {
type: "category",
data: devices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
axisLabel: { fontSize: 10, interval: 0 },
},
yAxis: { type: "value", name: "耗时(小时)" },
series: [
...series.map((s, si) => ({
name: s.name,
type: "bar" as const,
stack: "total",
barMaxWidth: 40,
itemStyle: si === series.length - 1 ? { borderRadius: [4, 4, 0, 0] } : undefined,
data: s.data,
})),
{
name: "总计",
type: "bar" as const,
barGap: "-100%",
barMaxWidth: 40,
itemStyle: { color: "transparent" },
label: {
show: true,
position: "top",
formatter: "{c}h",
fontWeight: "bold",
color: "#333",
},
data: totalData,
},
],
};
}, [flow]);
const capabilityEmpty = !capability || capability.series.length === 0;
const flowEmpty = !flow || flow.devices.length === 0;
return (
<div className="space-y-6">
{/* 页头 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 效能分析看板</h2>
<p className="mt-0.5 text-sm text-gray-400">个人能力图谱 / 设备流转对比 · ECharts 可视化</p>
</div>
</div>
{/* 顶部统一筛选栏 */}
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<RangePicker
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={["开始日期", "结束日期"]}
/>
<Select
mode="multiple"
size="small"
allowClear
placeholder="人员(可多选)"
value={assigneeIds}
onChange={setAssigneeIds}
options={options.assignees.map((a) => ({ value: a.id, label: `${a.name} (${a.id})` }))}
style={{ minWidth: 160, maxWidth: 240 }}
maxTagCount={1}
maxTagTextLength={4}
/>
<Select
mode="multiple"
size="small"
allowClear
placeholder="规格型号"
value={specModels}
onChange={setSpecModels}
options={options.spec_models.map((s) => ({ value: s, label: s }))}
style={{ minWidth: 160, maxWidth: 240 }}
maxTagCount={1}
maxTagTextLength={8}
/>
<Input
size="small"
allowClear
placeholder="设备身份证(可多个,逗号分隔)"
value={productSn}
onChange={(e) => setProductSn(e.target.value)}
style={{ width: 200 }}
/>
<Button size="small" type="primary" onClick={() => { loadCapability(); loadFlow(); }}>
查询
</Button>
<Button size="small" icon={<RefreshCw className="h-3 w-3" />} onClick={handleReset}>
重置
</Button>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
{/* 主体:两个可视化区块 */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: "capability",
label: (
<span className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
个人能力图谱
</span>
),
children: (
<div className="rounded-xl bg-white p-4 shadow-sm">
<h3 className="mb-2 text-sm font-semibold text-gray-700">个人能力图谱(单台设备耗时 · 点击柱子查看备注)</h3>
{capabilityLoading && !capability ? (
<div className="flex h-[420px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : capabilityEmpty ? (
<div className="flex h-[420px] items-center justify-center">
<Empty
description={assigneeIds.length === 0 ? "请先选择人员查看能力图谱" : "暂无数据"}
/>
</div>
) : (
<BaseEChart
option={capabilityOption}
height={420}
onZrClick={handleZrClick}
/>
)}
</div>
),
},
{
key: "flow",
label: (
<span className="flex items-center gap-1.5">
<GitBranch className="h-3.5 w-3.5" />
设备流转对比
</span>
),
children: (
<div className="rounded-xl bg-white p-4 shadow-sm">
<h3 className="mb-2 text-sm font-semibold text-gray-700">设备人员耗时对比 (按人堆叠)</h3>
{flowLoading && !flow ? (
<div className="flex h-[380px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : flowEmpty ? (
<div className="flex h-[380px] items-center justify-center">
<Empty
description={specModels.length === 0 && productSn.trim().length === 0 ? "请先选择规格型号或输入设备身份证查看流转" : "暂无数据"}
/>
</div>
) : (
<BaseEChart option={flowOption} height={380} onZrClick={handleFlowZrClick} />
)}
</div>
),
},
]}
/>
{/* 设备备注弹窗 */}
<Modal
title={`设备备注记录 — ${recordQuery?.sn ?? ""}`}
open={!!recordQuery}
onCancel={() => setRecordQuery(null)}
footer={null}
width={640}
>
{recordsLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : records.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-400">暂无备注记录</p>
) : (
<Timeline
items={records.map((r) => ({
color: STATUS_DOT[r.status] ?? "gray",
children: (
<div>
{/* 头部:工序名 · 操作人 + 状态徽章 + 时间 */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-800">{r.task_name || "—"}</span>
<span className="text-xs text-gray-400">· {r.assignee_name || "—"}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[r.status] ?? "bg-gray-100 text-gray-600"}`}>
{STATUS_LABEL[r.status] ?? r.status}
</span>
<span className="ml-auto text-xs text-gray-400">
{r.created_at ? dayjs(r.created_at).format("MM-DD HH:mm") : ""}
</span>
</div>
{/* 备注文本 */}
{r.remark && (
<div className="mt-1.5 text-sm leading-relaxed text-gray-700">{r.remark}</div>
)}
{/* 图片:PreviewGroup 包裹,点击全屏放大预览 */}
{r.images && r.images.length > 0 && (
<Image.PreviewGroup>
<div className="mt-2 flex flex-wrap gap-2">
{r.images.map((img, j) => (
<Image
key={j}
src={imageUrl(img)}
alt={`备注图片 ${j + 1}`}
width={64}
height={64}
className="rounded-md object-cover"
style={{ objectFit: "cover" }}
/>
))}
</div>
</Image.PreviewGroup>
)}
</div>
),
}))}
/>
)}
</Modal>
</div>
);
}