feat(前端): 新增 WIP 分布矩阵透视表页面

- MatrixBoard.tsx: Radio切换 按人员/按工序,动态交叉表转换(规格型号为行)
- 单元格显示设备数量+主负责人,首列规格固定左、末列合计固定右,bordered Excel风格
- 路由 /admin/matrix 注册,侧边栏新增「WIP 分布矩阵」入口
This commit is contained in:
2026-08-28 16:25:26 +08:00
parent 7547a886cc
commit fdd7465afb
4 changed files with 113 additions and 1 deletions

View File

@ -24,6 +24,7 @@ const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
export default function App() {
return (
@ -55,6 +56,7 @@ export default function App() {
<Route path="/admin/people" element={<AdminPeoplePage />} />
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
<Route path="/admin/analytics" element={<AnalyticsDashboard />} />
<Route path="/admin/matrix" element={<MatrixBoard />} />
</Route>
</Routes>
</Suspense>

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3 } from "lucide-react";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2 } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
@ -33,6 +33,12 @@ const MENU = [
icon: BarChart3,
description: "人员效能 / 设备流转 ECharts 可视化",
},
{
title: "WIP 分布矩阵",
path: "/admin/matrix",
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
];
export default function AdminLayout() {

View File

@ -0,0 +1,92 @@
/**
* WIP 分布矩阵 — 在制品透视表(规格型号 × 人员/工序,单元格=设备数量)
* Y 轴 = 规格型号X 轴 = 人员 或 工序,单元格 = 该交叉点的在制品设备数
*/
import { useEffect, useMemo, useState } from "react";
import { Radio, Table } from "antd";
import { fetchWipMatrix, type WipMatrixRow } from "../services/dashboardApi";
export default function MatrixBoard() {
const [dimension, setDimension] = useState<"assignee" | "task_name">("assignee");
const [data, setData] = useState<WipMatrixRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetchWipMatrix(dimension)
.then(setData)
.catch(() => setData([]))
.finally(() => setLoading(false));
}, [dimension]);
// 🔧 核心:扁平数组 → 动态交叉表规格型号为行、dimension_key 为列)
const { columns, dataSource } = useMemo(() => {
const keys = Array.from(new Set(data.map((d) => d.dimension_key)));
const rowsMap = new Map<string, any>();
for (const item of data) {
if (!rowsMap.has(item.spec_model)) {
rowsMap.set(item.spec_model, { spec_model: item.spec_model, _assignees: {}, row_total: 0 });
}
const row = rowsMap.get(item.spec_model);
row[item.dimension_key] = (row[item.dimension_key] || 0) + item.count;
row.row_total += item.count;
// 记录每个交叉点的主负责人(中文名,去重)
row._assignees[item.dimension_key] = item.assignees || [];
}
const ds = Array.from(rowsMap.values());
const cols: any[] = [
{ title: "规格型号", dataIndex: "spec_model", fixed: "left", width: 190, render: (v: string) => <span className="font-semibold text-gray-700">{v}</span> },
...keys.map((key) => ({
title: key,
dataIndex: key,
align: "center" as const,
width: 120,
render: (val: number, record: any) => {
const assignees = record._assignees?.[key] || [];
return (
<div className="text-center">
<div className="text-sm font-bold text-gray-800">{val || 0}</div>
{assignees.length > 0 && (
<div className="max-w-[100px] truncate text-[10px] text-gray-400" title={assignees.join("、")}>
{assignees.join("、")}
</div>
)}
</div>
);
},
})),
{ title: "合计", dataIndex: "row_total", fixed: "right" as const, align: "center" as const, width: 90, render: (v: number) => <span className="font-bold text-blue-600">{v}</span> },
];
return { columns: cols, dataSource: ds };
}, [data]);
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 WIP </h2>
<p className="mt-1 text-sm text-gray-500"> × {dimension === "assignee" ? "人员" : "工序"} · </p>
</div>
<Radio.Group value={dimension} onChange={(e) => setDimension(e.target.value)} optionType="button" buttonStyle="solid">
<Radio.Button value="assignee"></Radio.Button>
<Radio.Button value="task_name"></Radio.Button>
</Radio.Group>
</div>
<div className="rounded-xl bg-white p-4 shadow-sm">
<Table
columns={columns}
dataSource={dataSource}
loading={loading}
rowKey="spec_model"
bordered
size="small"
pagination={false}
scroll={{ x: "max-content" }}
/>
</div>
</div>
);
}

View File

@ -71,6 +71,13 @@ export interface OperationDetail {
time: string;
}
export interface WipMatrixRow {
spec_model: string;
dimension_key: string;
count: number;
assignees: string[];
}
export interface PersonDevice {
product_id: string;
serial_number: string;
@ -177,6 +184,11 @@ export async function fetchOperationDetail(
return data;
}
export async function fetchWipMatrix(dimension: "assignee" | "task_name"): Promise<WipMatrixRow[]> {
const { data } = await api.get<WipMatrixRow[]>("/dashboard/wip-matrix", { params: { dimension } });
return data;
}
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
return data;