Files
track/frontend/src/pages/admin/AdminTasksPage.tsx
duxingchen e45c97bd1f feat: 组织隔离(IRIS 单实例)与出料功能基础
本轮之前累积的未提交工作,一并固化:

- 组织隔离:同一份代码部署给不同部门只需改 config 的 ORG_DEPARTMENT 与
  MATERIAL_CATEGORY_PREFIX。过滤点在登录/人员列表/物料/MOM 出库单四处,
  全部服务端钉死,客户端传什么都放不大。
  ★ 物料必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
  `LICA/<中文>`,而本部门分类树里另有 `IRIS/成品/LICA/…`(本就属于本部门),
  前缀匹配天然区分得开。
- MOM 出库单只读查询(直连 MOM 库):不走 MOM 现成的 /outbound 接口 ——
  那个要 JWT + permission_required,且对非特权账号按 consumer_name 做行级
  隔离,服务账号只能拿到自己名下的单。分页必须两段式(先按单号 GROUP BY
  分页,再 IN 捞明细),对宽表直接分页会得到明细行数而不是单据数。
- 出料功能:产品 ↔ 出库单存档(product_outbounds)与任务 ↔ 出库明细
  (task_outbound_materials),供「这台设备对应 MOM 哪张单」的展示。
  ⚠️ 快照一律由后端拿 ID 去 MOM 现查,不接受前端传入,否则前端可伪造单据。
2026-09-23 15:18:06 +08:00

949 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 任务全景 Dashboard — 按订单聚合 + 关键词搜索 + 状态筛选 */
import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns, Plus,
} from "lucide-react";
import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
import api from "../../services/api";
import { scanProduct } from "../../services/productApi";
import {
receiveTask, rejectTask, transferTask,
} from "../../services/taskApi";
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import CreateTaskDialog from "../../components/admin/CreateTaskDialog";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
import { extractErrorMessage } from "../../utils/errorMessage";
const STATUS_TABS: { key: string; label: string; accent?: boolean }[] = [
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
{ key: "OUTBOUND", label: "已出库" },
// 🔧 售后流转中 = 出库回流设备中正在流转的(lifecycle_phase=AFTER_SALES 且 当前 WIP)。
// 它与上面几个"状态码"不是同一维度(靠 lifecycle_phase 判定),故单列一个独立 key;
// 多选时与其它状态是「或」关系 —— 只勾它即可一键剥离全部生产数据。
{ key: "AFTER_SALES_WIP", label: "售后流转中", accent: true },
];
type SortOrder = "asc" | "desc";
interface ColumnDef {
key: string;
label: string;
colSpan: number;
sortable?: boolean;
sortValue?: (p: ProductResponse) => string | number;
filterType?: "text" | "enum";
getFilterValue?: (p: ProductResponse) => string;
enumOptions?: { value: string; label: string }[];
render: (p: ProductResponse) => React.ReactNode;
}
interface OrderGroup {
orderNo: string;
products: ProductResponse[];
allInWarehouse: boolean;
allCompleted: boolean;
}
/** 滞留时长格式化:小时 → 天/小时/分钟 */
function formatDuration(hours: number | null): string {
if (hours == null) return "—";
if (hours >= 24) return `${Math.round(hours / 24)}天`;
if (hours >= 1) return `${Math.round(hours)}小时`;
return `${Math.max(1, Math.round(hours * 60))}分钟`;
}
export default function AdminTasksPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
// 搜索 & 筛选
// 从大屏下钻: ?search=xxx 灌进搜索框、?status=WIP,ARCHIVED 点亮状态 Tabs
// —— 直接复用原生筛选状态,表现与用户手点完全一致,不引入任何额外 UI
const [keyword, setKeyword] = useState(() => searchParams.get("search") || "");
const [statusFilters, setStatusFilters] = useState<Set<string>>(
() => new Set((searchParams.get("status") || "").split(",").map((s) => s.trim()).filter(Boolean)),
);
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 手风琴展开状态
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 🔧 排他展开:同时只展开一个产品的流转树
const [activeTreeProductId, setActiveTreeProductId] = useState<string | null>(null);
const [taskTrees, setTaskTrees] = useState<Record<string, ProductScanResponse | null>>({});
const [treeLoading, setTreeLoading] = useState<Record<string, boolean>>({});
// 弹窗
const [modalTarget, setModalTarget] = useState<ModalTarget | null>(null);
const [submitting, setSubmitting] = useState(false);
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
// 🔧 创建任务弹窗 —— 产品由所点的那一行确定,弹窗里再选工序/接收人/出库物料
const [createTarget, setCreateTarget] = useState<ProductResponse | null>(null);
// ---- 列配置(10列)----
const columns: ColumnDef[] = [
{
key: "serial_number", label: "产品身份证", colSpan: 2,
sortable: true, sortValue: (p) => p.serial_number,
filterType: "text", getFilterValue: (p) => p.serial_number,
render: (p) => (
<div className="font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
),
},
{
key: "external_serial", label: "序列号", colSpan: 1,
sortable: true, sortValue: (p) => p.external_serial || "",
filterType: "text", getFilterValue: (p) => p.external_serial || "",
render: (p) => <div className="font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>,
},
{
key: "spec", label: "规格型号", colSpan: 2,
filterType: "text", getFilterValue: (p) => p.spec_model || p.material_name || p.material_id || "",
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
},
{
key: "current_step", label: "当前工序", colSpan: 1,
// 🔧 改用后端派生的 current_step,而不是 overall_status ——
// 后者的取值被"最新主干任务名"覆盖(含已 COMPLETED 的历史工序),
// 会让「扫码出库 / 测试 / 发货测试」等已完结工序冒充"当前工序"。
// current_step 只认活跃主干任务;无活跃任务时返回终态或空。
filterType: "enum", getFilterValue: (p) => p.current_step || "—",
// 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来
enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })),
render: (p) => {
const lb = lifecycleBadge(p.current_step, p.lifecycle_phase);
return (
<span className="flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700">{p.current_step || "—"}</span>
{lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label}
</span>
)}
</span>
);
},
},
{
key: "status", label: "产品状态", colSpan: 1,
filterType: "enum", getFilterValue: (p) => (p.macro_status || p.status).toUpperCase(),
enumOptions: [
{ value: "PENDING", label: "待接收" },
{ value: "WIP", label: "进行中" },
{ value: "COMPLETED", label: "已完成" },
{ value: "ARCHIVED", label: "已入库" },
{ value: "OUTBOUND", label: "已出库" },
],
render: (p) => {
const statusCfg = getStatusConfig(p.macro_status || p.status);
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span>;
},
},
{
key: "location", label: "当前位置", colSpan: 1,
filterType: "enum",
getFilterValue: (p) => p.current_location_id === "virtual_warehouse" ? "仓库" : (p.current_location_name || p.current_location_id || "—"),
render: (p) => (
<div className="text-xs text-gray-500 truncate">
{p.current_location_id === "virtual_warehouse" ? <span className="inline-flex items-center gap-1 text-purple-600">🏭 仓库</span> : (p.current_location_name || p.current_location_id || "—")}
</div>
),
},
{
key: "active_duration", label: "当前人滞留", colSpan: 1,
sortable: true, sortValue: (p) => p.active_duration_hours ?? -1,
render: (p) => {
const h = p.active_duration_hours;
if (h == null) return <span className="text-xs text-gray-300">—</span>;
const cls = h >= 24 ? "bg-red-50 text-red-600" : h >= 1 ? "bg-orange-50 text-orange-600" : "bg-emerald-50 text-emerald-600";
return <span className={`rounded-md px-1.5 py-0.5 text-xs font-bold ${cls}`}>{formatDuration(h)}</span>;
},
},
{
key: "latest", label: "最新动态", colSpan: 2,
sortable: true, sortValue: (p) => p.latest_record_time ? new Date(p.latest_record_time).getTime() : 0,
filterType: "text",
getFilterValue: (p) => {
const t = p.latest_record_time ? new Date(p.latest_record_time).toLocaleString("zh-CN") : "";
return `${p.latest_record_assignee_name || ""} ${p.latest_record_content || ""} ${t}`;
},
render: (p) => (
p.latest_record_time ? (
<Tooltip title={(p.latest_record_assignee_name ? `${p.latest_record_assignee_name}: ` : "") + (p.latest_record_content || "") + (p.latest_record_has_images ? " [含图片]" : "")}>
<div className="cursor-default">
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
<div className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-gray-600">
{p.latest_record_has_images && <span className="shrink-0">📷</span>}
<span className="truncate">
{p.latest_record_assignee_name && <span className="font-medium text-gray-700">{p.latest_record_assignee_name}: </span>}
{p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")}
</span>
</div>
</div>
</Tooltip>
) : <span className="text-gray-300">—</span>
),
},
{
key: "created_at", label: "创建时间", colSpan: 2,
sortable: true, sortValue: (p) => new Date(p.created_at).getTime(),
filterType: "text", getFilterValue: (p) => new Date(p.created_at).toLocaleDateString("zh-CN"),
render: (p) => <div className="text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>,
},
{
key: "actions", label: "操作", colSpan: 2,
render: (p) => {
const isTreeLoading = treeLoading[p.serial_number];
return (
<div className="flex items-center gap-1.5">
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
流转树
</button>
{/* 创建任务:产品由本行确定,弹窗里再选工序/接收人/出库物料 */}
<button onClick={() => setCreateTarget(p)} className="flex items-center gap-1 rounded border border-emerald-200 px-2.5 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-50 transition-colors">
<Plus className="h-3 w-3" />
创建任务
</button>
</div>
);
},
},
];
// ---- 列顺序 / 行排序 / 列筛选状态 ----
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
const [textFilters, setTextFilters] = useState<Record<string, string>>({});
// 从大屏下钻: ?stage=测试 → 点亮「当前工序」列的筛选(该列显示值对空状态用「—」,
// 故「待启动」需折算成「—」才能命中)
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>(() => {
const stage = searchParams.get("stage");
const initial: Record<string, Set<string>> = {};
if (stage) initial.overall_status = new Set([stage === "待启动" ? "—" : stage]);
return initial;
});
const [dragCol, setDragCol] = useState<string | null>(null);
const [dragOverCol, setDragOverCol] = useState<string | null>(null);
// 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示)
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
// 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树
const autoSn = searchParams.get("sn") || "";
// ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ----
async function loadProducts(kw: string) {
setLoading(true);
setError(null);
try {
const params: Record<string, string | number> = { limit: 1000 };
if (kw.trim()) params.keyword = kw.trim();
const { data } = await api.get<ProductResponse[]>("/products/", { params });
setProducts(data);
return data;
} catch {
setError("加载产品列表失败,请检查后端服务");
return [];
} finally {
setLoading(false);
}
}
useEffect(() => {
if (autoSn) {
setKeyword(autoSn);
loadProducts(autoSn).then((data) => {
// 产品加载完成后自动打开流转树弹窗
const found = data.find((p: ProductResponse) => p.serial_number === autoSn);
if (found) openTreeModal(found.serial_number);
});
} else {
loadProducts(keyword);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
setExpandedOrders(new Set());
setTaskTrees({});
setActiveTreeProductId(null);
loadProducts(keyword);
}
// 🚀 二维码弹窗打开时锁定背景滚动(防止滚动穿透)
useEffect(() => {
if (!qrSerial) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [qrSerial]);
// ---- 按订单/设备分组 + 状态过滤 + 列筛选 + 组内排序 ----
const orderGroups = useMemo<OrderGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilters.size > 0) {
const currentStatus = (p.macro_status || p.status).toUpperCase();
// 🔧 「售后流转中」按 lifecycle_phase 判定,与状态码不同维,单独算:
// 只勾它时,未勾任何状态码 → 仅留下售后回流且正在流转的设备。
const isAfterSalesWip =
p.lifecycle_phase === "AFTER_SALES" && currentStatus === "WIP";
const matchedByStatus = statusFilters.has(currentStatus);
const matchedByAfterSales = statusFilters.has("AFTER_SALES_WIP") && isAfterSalesWip;
if (!matchedByStatus && !matchedByAfterSales) continue;
}
// 列筛选(所有列)
let skip = false;
for (const col of columns) {
if (col.filterType === "text") {
const kw = (textFilters[col.key] || "").trim().toLowerCase();
if (kw && !(col.getFilterValue!(p) || "").toLowerCase().includes(kw)) { skip = true; break; }
} else if (col.filterType === "enum") {
const set = enumFilters[col.key];
if (set && set.size > 0 && !set.has(col.getFilterValue!(p))) { skip = true; break; }
}
}
if (skip) continue;
const key = groupBy === "device"
? (p.material_name || p.material_id || "未命名设备")
: (p.order_no || "未绑定订单");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([orderNo, prods]) => {
// 组内排序(升/降)
let sorted = prods;
if (sort) {
const col = columns.find((c) => c.key === sort!.key);
if (col?.sortValue) {
sorted = [...prods].sort((a, b) => {
const va = col.sortValue!(a);
const vb = col.sortValue!(b);
const cmp = (typeof va === "number" && typeof vb === "number")
? va - vb
: String(va ?? "").localeCompare(String(vb ?? ""));
return sort!.order === "asc" ? cmp : -cmp;
});
}
}
return {
orderNo,
products: sorted,
// 已入库 = MOM 已扫码实收 (macro_status==ARCHIVED),而非仅"在仓库位置"
allInWarehouse: prods.every(
(p) => (p.macro_status || p.status).toUpperCase() === "ARCHIVED"
),
allCompleted: prods.every(
(p) => (p.macro_status || p.status).toUpperCase() === "COMPLETED"
),
};
});
}, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps
/** 删除 URL 上的指定跳转预设参数(只删传入的 key,不动其它无关参数) */
function clearUrlPresets(keys: string[]) {
const hit = keys.filter((k) => searchParams.has(k));
if (hit.length === 0) return;
const next = new URLSearchParams(searchParams);
hit.forEach((k) => next.delete(k));
setSearchParams(next, { replace: true });
}
/**
* 解锁大屏带过来的 ?stage —— URL 参数与本地列筛选一起清掉。
*
* 该筛选只应在「从大屏跳进来的那一刻」生效一次。它藏在列筛选浮层里,
* 界面上几乎不可见,若不清除会与状态 Tab 叠加成互斥条件、筛出空列表,
* 用户却找不到原因。用户手动改了任何分类条件 = 最新意图优先。
*/
function clearStageLock() {
clearUrlPresets(["stage"]);
setEnumFilters((prev) => {
if (!prev.overall_status) return prev;
const next = { ...prev };
delete next.overall_status;
return next;
});
}
/** 全局重置:清空全部筛选条件与跳转预设,并强制一次无条件全量查询 */
function handleReset() {
// stage(工序锁)与 sn(设备定位)都是跳转带进来的预设,重置时一并丢弃,
// 否则用户刷新页面会被旧条件"复活"回去
clearUrlPresets(["stage", "sn"]);
setKeyword("");
setStatusFilters(new Set());
setTextFilters({});
setEnumFilters({});
setSort(null);
setExpandedOrders(new Set());
setTaskTrees({});
setActiveTreeProductId(null);
loadProducts("");
}
// 用户手动切换状态分类 = 最新意图,丢弃大屏带来的工序限制
function toggleStatusFilter(key: string) {
clearStageLock();
setStatusFilters((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// ---- 列头排序:无 → 升序 → 降序 → 无 ----
function toggleSort(key: string) {
setSort((prev) => {
if (!prev || prev.key !== key) return { key, order: "asc" };
if (prev.order === "asc") return { key, order: "desc" };
return null;
});
}
// ---- 列显隐:勾选=显示,取消=隐藏(操作列始终显示) ----
function toggleColumnVisible(key: string) {
setHiddenColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// ---- 枚举列多选筛选 ----
function toggleEnumFilter(key: string, value: string) {
setEnumFilters((prev) => {
const cur = prev[key] || new Set<string>();
const next = new Set(cur);
if (next.has(value)) next.delete(value); else next.add(value);
const copy = { ...prev };
if (next.size === 0) delete copy[key];
else copy[key] = next;
return copy;
});
}
// ---- 列头拖拽换序 ----
function onDragStart(e: React.DragEvent, key: string) {
setDragCol(key);
e.dataTransfer.effectAllowed = "move";
}
function onDragOver(e: React.DragEvent, key: string) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverCol(key);
}
function onDrop(e: React.DragEvent, targetKey: string) {
e.preventDefault();
if (!dragCol || dragCol === targetKey) { setDragCol(null); setDragOverCol(null); return; }
setColumnOrder((prev) => {
const next = [...prev];
const from = next.indexOf(dragCol);
const to = next.indexOf(targetKey);
if (from < 0 || to < 0) return prev;
next.splice(from, 1);
next.splice(to, 0, dragCol);
return next;
});
setDragCol(null);
setDragOverCol(null);
}
// 🔧 默认全部展开
useEffect(() => {
setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)));
}, [orderGroups]);
// ---- 手风琴切换 ----
function toggleOrder(orderNo: string) {
setExpandedOrders((prev) => {
const next = new Set(prev);
if (next.has(orderNo)) next.delete(orderNo);
else next.add(orderNo);
return next;
});
}
// ---- 流转树宽屏弹窗(懒加载,缓存到 taskTrees) ----
function openTreeModal(serialNumber: string) {
setActiveTreeProductId(serialNumber);
if (!taskTrees[serialNumber]) {
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
scanProduct(serialNumber)
.then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result })))
.catch((err: unknown) => toast(extractErrorMessage(err, "加载任务树失败"), "error"))
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
}
}
function closeTreeModal() {
setActiveTreeProductId(null);
}
// ---- 任务操作 ----
const refreshProductTree = useCallback(async (serialNumber: string) => {
try {
const result = await scanProduct(serialNumber);
setTaskTrees((s) => ({ ...s, [serialNumber]: result }));
} catch { /* 静默 */ }
}, []);
async function handleReceive() {
if (!modalTarget) return;
setSubmitting(true);
try {
await receiveTask(modalTarget.task.id);
toast("任务已接收", "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(extractErrorMessage(err, "接收失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string, images: string[]) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason, images);
toast("任务已驳回", "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(extractErrorMessage(err, "驳回失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleTransfer(
nextTaskName: string,
assignees: string[],
note: string,
finishDirectly: boolean,
) {
if (!modalTarget) return;
setSubmitting(true);
try {
const result = await transferTask(
modalTarget.task.id, assignees, nextTaskName, note || undefined, finishDirectly,
);
toast(result.message, "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(extractErrorMessage(err, "转交失败"), "error");
} finally {
setSubmitting(false);
}
}
// ---- 渲染 ----
const modalTask = modalTarget?.task ?? null;
// 按当前列顺序渲染的列
const orderedColumns = columnOrder
.map((key) => columns.find((c) => c.key === key))
.filter((c): c is ColumnDef => !!c);
// 🔧 实际显示的列(排除被隐藏的)+ 动态总跨度
const visibleColumns = orderedColumns.filter((c) => !hiddenColumns.has(c.key));
const visibleSpan = visibleColumns.reduce((s, c) => s + c.colSpan, 0);
// 列设置面板内容(列显隐)
function renderColumnPanel() {
const toggleable = columns.filter((c) => c.key !== "actions");
return (
<div className="max-h-80 w-48 overflow-auto p-2">
{toggleable.map((col) => (
<label key={col.key} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
<Checkbox checked={!hiddenColumns.has(col.key)} onChange={() => toggleColumnVisible(col.key)} />
<span className="truncate">{col.label}</span>
</label>
))}
<div className="mt-1 flex items-center justify-between border-t border-gray-100 pt-1.5">
<button onClick={() => setHiddenColumns(new Set())} className="text-xs text-blue-600 hover:underline">全部显示</button>
<span className="text-[10px] text-gray-300">操作列固定</span>
</div>
</div>
);
}
// 列筛选面板内容
function renderFilterPanel(col: ColumnDef) {
if (col.filterType === "text") {
return (
<div className="w-52 p-2">
<Input
size="small"
placeholder={`筛选${col.label}`}
value={textFilters[col.key] || ""}
onChange={(e) => setTextFilters((prev) => ({ ...prev, [col.key]: e.target.value }))}
allowClear
/>
</div>
);
}
// enum:优先固定选项,否则从当前数据动态去重
const options = col.enumOptions
|| Array.from(new Set(products.map((p) => col.getFilterValue!(p)))).filter(Boolean).map((v) => ({ value: v, label: v }));
const selected = enumFilters[col.key] || new Set<string>();
return (
<div className="max-h-64 w-44 overflow-auto p-2">
{options.length === 0 ? (
<div className="py-2 text-center text-xs text-gray-400">无可用选项</div>
) : options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
<Checkbox checked={selected.has(opt.value)} onChange={() => toggleEnumFilter(col.key, opt.value)} />
<span className="truncate">{opt.label}</span>
</label>
))}
</div>
);
}
return (
<div>
{/* ---- 标题 ---- */}
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-800">任务全景 Dashboard</h2>
<p className="mt-1 text-sm text-gray-500">
按订单聚合查看产品流转状态,支持多维搜索与状态筛选
</p>
</div>
{/* ---- 搜索 + 筛选 ---- */}
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">分组:</span>
{(["order", "device"] as const).map(m => (
<button key={m} onClick={() => setGroupBy(m)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${groupBy === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-500 hover:bg-gray-200"}`}>
{m === "order" ? "按订单" : "按设备"}
</button>
))}
</div>
<div className="flex items-center gap-1">
<Popover trigger="click" placement="bottomRight" content={renderColumnPanel()}>
<button className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 hover:text-blue-600">
<Columns className="h-3 w-3" />列设置
</button>
</Popover>
<button onClick={() => setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)))}
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50">全部展开</button>
<button onClick={() => setExpandedOrders(new Set())}
className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100">全部折叠</button>
</div>
</div>
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索产品身份证、业务序列号、订单号、名称、规格型号..."
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-8 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{keyword && (
<button
type="button"
onClick={() => { setKeyword(""); loadProducts(""); }}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<button
type="submit"
disabled={loading}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
查询
</button>
<Button onClick={handleReset} disabled={loading}>重置</Button>
</form>
{/* 状态筛选 Tabs(多选) */}
<div className="mt-3 flex gap-1.5 flex-wrap">
<button
onClick={() => { clearStageLock(); setStatusFilters(new Set()); }}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilters.size === 0
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
全部
</button>
{STATUS_TABS.map((tab) => {
const active = statusFilters.has(tab.key);
return (
<button
key={tab.key}
onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
active
? tab.accent ? "bg-purple-600 text-white" : "bg-blue-600 text-white"
: tab.accent ? "bg-purple-50 text-purple-700 hover:bg-purple-100" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{tab.label}
</button>
);
})}
</div>
</div>
{/* ---- 错误 ---- */}
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* ---- 加载中 ---- */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
)}
{/* ---- 空态 ---- */}
{!loading && products.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Package className="mb-3 h-12 w-12" />
<p>暂无产品数据</p>
<p className="mt-1 text-sm">创建产品并绑定订单后,在此查看流转状态</p>
</div>
)}
{/* ---- 订单手风琴列表 ---- */}
{!loading && orderGroups.length > 0 && (
<div className="space-y-3">
{orderGroups.map((group) => {
const isOpen = expandedOrders.has(group.orderNo);
return (
<div
key={group.orderNo}
className="overflow-hidden rounded-xl bg-white shadow-sm"
>
{/* 订单头部 */}
<button
onClick={() => toggleOrder(group.orderNo)}
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />
)}
<Package className="h-4 w-4 shrink-0 text-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">
{group.orderNo === "未绑定订单" || group.orderNo === "未命名设备"
? "📋 未分类"
: groupBy === "device"
? `⚙️ 设备: ${group.orderNo}`
: `📦 订单: ${group.orderNo}`}
</span>
<span className="text-xs text-gray-400">
{group.products.length} 个产品
</span>
</div>
</div>
{/* 入库状态标签 */}
{group.allInWarehouse && group.products.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">
<Warehouse className="h-3 w-3" />
已全部入库
</span>
)}
{group.allCompleted && !group.allInWarehouse && group.products.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-600">
<Warehouse className="h-3 w-3" />
已完工待入库
</span>
)}
{!group.allInWarehouse && !group.allCompleted && (
<span className="text-xs text-gray-400">流转中</span>
)}
</button>
{/* 订单展开内容 */}
{isOpen && (
<div className="border-t border-gray-100">
{/* 表头(可排序 / 可筛选 / 可拖拽换列 / 可显隐) */}
<div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
{visibleColumns.map((col) => {
const sorted = sort?.key === col.key;
const hasFilter = col.filterType === "text"
? !!(textFilters[col.key] || "").trim()
: !!enumFilters[col.key]?.size;
return (
<div
key={col.key}
draggable
onDragStart={(e) => onDragStart(e, col.key)}
onDragOver={(e) => onDragOver(e, col.key)}
onDrop={(e) => onDrop(e, col.key)}
onDragEnd={() => { setDragCol(null); setDragOverCol(null); }}
className={`flex select-none items-center gap-1 rounded ${dragOverCol === col.key ? "bg-blue-100 ring-2 ring-blue-300" : ""}`}
style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}
>
<span
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
className={`flex items-center gap-0.5 ${col.sortable ? "cursor-pointer hover:text-blue-600" : ""}`}
>
{col.label}
{col.sortable && (
sorted
? (sort!.order === "asc" ? <ArrowUp className="h-3 w-3 text-blue-600" /> : <ArrowDown className="h-3 w-3 text-blue-600" />)
: <ArrowUpDown className="h-3 w-3 text-gray-300" />
)}
</span>
{col.filterType && (
<Popover trigger="click" placement="bottomLeft" content={renderFilterPanel(col)}>
<button
onClick={(e) => e.stopPropagation()}
className={`rounded p-0.5 hover:bg-gray-200 ${hasFilter ? "text-blue-600" : "text-gray-400"}`}
>
<Filter className="h-3 w-3" />
</button>
</Popover>
)}
</div>
);
})}
</div>
{/* 产品行 */}
{group.products.map((p) => {
return (
<div key={p.id}>
<div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
{visibleColumns.map((col) => (
<div key={col.key} style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}>
{col.render(p)}
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* 🔧 二维码弹窗 */}
{qrSerial && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setQrSerial(null)}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div className="relative z-10 rounded-xl bg-white p-6 shadow-2xl text-center" onClick={e => e.stopPropagation()}>
<h3 className="mb-3 text-sm font-bold text-gray-800">产品二维码</h3>
<img src={`/api/v1/products/qrcode/${qrSerial}`} alt={`QR-${qrSerial}`} className="mx-auto h-64 w-64 rounded-lg border" />
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-700">{qrSerial}</p>
<button onClick={() => setQrSerial(null)} className="mt-4 rounded-lg bg-blue-600 px-6 py-2 text-sm text-white hover:bg-blue-700">关闭</button>
</div>
</div>
)}
{/* 🔧 流转树宽屏弹窗 */}
{activeTreeProductId && (
<Modal
open
onClose={closeTreeModal}
title={`🔀 流转树 — ${activeTreeProductId}`}
widthClass="max-w-6xl"
bodyClassName="max-h-[90vh] overflow-y-auto"
>
{treeLoading[activeTreeProductId] ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
</div>
) : taskTrees[activeTreeProductId]?.task_tree?.length ? (
<TaskFlowView
tasks={taskTrees[activeTreeProductId].task_tree}
onAction={setModalTarget}
currentUser={currentUser}
assigneeNames={taskTrees[activeTreeProductId].assignee_names}
size="lg"
/>
) : (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<GitBranch className="mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-500">暂无流转记录</p>
<p className="mt-1 text-xs text-gray-400">产品刚创建,尚未分配生产任务</p>
</div>
)}
</Modal>
)}
{/* ---- 弹窗 ---- */}
<ReceiveConfirmModal
open={modalTarget?.action === "receive"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onConfirm={handleReceive}
/>
<RejectModal
open={modalTarget?.action === "reject"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onSubmit={handleReject}
/>
<TransferModal
open={modalTarget?.action === "transfer"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onSubmit={handleTransfer}
/>
{/* 🔧 创建任务弹窗(含从 MOM 出库单选物料) */}
<CreateTaskDialog
open={createTarget !== null}
product={createTarget}
onClose={() => setCreateTarget(null)}
onCreated={() => { loadProducts(keyword); }}
/>
</div>
);
}