From 19c7c7bbf1ff79b065a0bc5fab4e60ccb35bcf9a Mon Sep 17 00:00:00 2001 From: duxingchen Date: Fri, 28 Aug 2026 10:46:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BB=BB=E5=8A=A1=E5=85=A8=E6=99=AF):=20?= =?UTF-8?q?=E8=A1=A8=E5=A4=B4=E6=8E=92=E5=BA=8F/=E7=AD=9B=E9=80=89/?= =?UTF-8?q?=E5=88=97=E6=8B=96=E6=8B=BD=20+=20=E6=96=B0=E5=A2=9E=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E4=BA=BA=E6=BB=9E=E7=95=99=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态筛选 tabs 去掉「已入库」并支持多选 - 表头点击排序:身份证/序列号/当前人滞留/最新动态/创建时间,三态循环(升/降/取消) - 表头漏斗筛选:文本列模糊过滤,枚举列(宏观状态/任务状态/当前位置)多选过滤 - 列头拖拽调整列显示顺序 - 新增「当前人滞留」列,读 active_duration_hours 按天/小时/分钟着色展示 - 修复 handleSearch 引用不存在的 setExpandedProducts(应为此前的展开状态) 导致的查询报错 --- frontend/src/pages/admin/AdminTasksPage.tsx | 439 ++++++++++++++++---- 1 file changed, 352 insertions(+), 87 deletions(-) diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index 301dbe1..07ea536 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -3,9 +3,9 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import { Search, Loader2, Package, ChevronDown, ChevronRight, - Warehouse, GitBranch, X, + Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, } from "lucide-react"; -import { Tooltip } from "antd"; +import { Tooltip, Popover, Checkbox, Input } from "antd"; import api from "../../services/api"; import { scanProduct } from "../../services/productApi"; import { @@ -15,25 +15,45 @@ import { TaskFlowView } from "../../components/TaskTree/TaskFlowView"; import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer"; import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer"; import type { ProductResponse } from "../../types/admin"; -import type { ProductScanResponse, TaskResponse } from "../../types/api"; +import type { ProductScanResponse } from "../../types/api"; import { useToast } from "../../components/ui/Toast"; import { getStatusConfig } from "../../constants/task"; import { useAuth } from "../../contexts/AuthContext"; const STATUS_TABS = [ - { key: "", label: "全部" }, { key: "PENDING", label: "待接收" }, { key: "WIP", label: "进行中" }, { key: "COMPLETED", label: "已完成" }, - { key: "ARCHIVED", label: "已入库" }, ]; +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; } +/** 滞留时长格式化:小时 → 天/小时/分钟 */ +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(); @@ -41,7 +61,7 @@ export default function AdminTasksPage() { // 搜索 & 筛选 const [keyword, setKeyword] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); + const [statusFilters, setStatusFilters] = useState>(new Set()); const [groupBy, setGroupBy] = useState<"order" | "device">("device"); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(false); @@ -60,6 +80,122 @@ export default function AdminTasksPage() { const [submitting, setSubmitting] = useState(false); const [qrSerial, setQrSerial] = useState(null); // 🔧 QR弹窗 + // ---- 列配置(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) => ( +
setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}
+ ), + }, + { + key: "external_serial", label: "序列号", colSpan: 1, + sortable: true, sortValue: (p) => p.external_serial || "", + filterType: "text", getFilterValue: (p) => p.external_serial || "", + render: (p) =>
{p.external_serial || "—"}
, + }, + { + key: "spec", label: "规格型号", colSpan: 2, + filterType: "text", getFilterValue: (p) => p.spec_model || p.material_name || p.material_id || "", + render: (p) =>
{p.spec_model || p.material_name || p.material_id || "—"}
, + }, + { + key: "overall_status", label: "宏观状态", colSpan: 1, + filterType: "enum", getFilterValue: (p) => p.overall_status || "—", + enumOptions: ["备货", "生产", "测试", "维修", "在库"].map((v) => ({ value: v, label: v })), + render: (p) => {p.overall_status || "—"}, + }, + { + 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: "已完成" }, + ], + render: (p) => { + const statusCfg = getStatusConfig(p.macro_status || p.status); + return {statusCfg.label}; + }, + }, + { + 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) => ( +
+ {p.current_location_id === "virtual_warehouse" ? 🏭 仓库 : (p.current_location_name || p.current_location_id || "—")} +
+ ), + }, + { + 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 ; + 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 {formatDuration(h)}; + }, + }, + { + 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 ? ( + +
+
{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
+
+ {p.latest_record_has_images && 📷} + + {p.latest_record_assignee_name && {p.latest_record_assignee_name}: } + {p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")} + +
+
+
+ ) : + ), + }, + { + 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) =>
{new Date(p.created_at).toLocaleDateString("zh-CN")}
, + }, + { + key: "actions", label: "操作", colSpan: 2, + render: (p) => { + const productExpanded = activeTreeProductId === p.serial_number; + const isTreeLoading = treeLoading[p.serial_number]; + return ( + + ); + }, + }, + ]; + + const TOTAL_SPAN = columns.reduce((s, c) => s + c.colSpan, 0); + + // ---- 列顺序 / 行排序 / 列筛选状态 ---- + const [columnOrder, setColumnOrder] = useState(() => columns.map((c) => c.key)); + const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null); + const [textFilters, setTextFilters] = useState>({}); + const [enumFilters, setEnumFilters] = useState>>({}); + const [dragCol, setDragCol] = useState(null); + const [dragOverCol, setDragOverCol] = useState(null); + // 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树 const autoSn = searchParams.get("sn") || ""; @@ -96,19 +232,31 @@ export default function AdminTasksPage() { function handleSearch(e?: React.FormEvent) { e?.preventDefault(); - setExpandedProducts(new Set()); + setExpandedOrders(new Set()); setTaskTrees({}); loadProducts(keyword); } - // ---- 按订单/设备分组 + 本地状态过滤 ---- + // ---- 按订单/设备分组 + 状态过滤 + 列筛选 + 组内排序 ---- const orderGroups = useMemo(() => { const map = new Map(); for (const p of products) { - if (statusFilter) { + if (statusFilters.size > 0) { const currentStatus = (p.macro_status || p.status).toUpperCase(); - if (currentStatus !== statusFilter) continue; + if (!statusFilters.has(currentStatus)) 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 || "未绑定订单"); @@ -117,14 +265,87 @@ export default function AdminTasksPage() { } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) - .map(([orderNo, prods]) => ({ - orderNo, - products: prods, - allInWarehouse: prods.every( - (p) => p.current_location_id === "virtual_warehouse" - ), - })); - }, [products, statusFilter, groupBy]); + .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, + allInWarehouse: prods.every( + (p) => p.current_location_id === "virtual_warehouse" + ), + }; + }); + }, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps + + function toggleStatusFilter(key: string) { + 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 toggleEnumFilter(key: string, value: string) { + setEnumFilters((prev) => { + const cur = prev[key] || new Set(); + 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(() => { @@ -227,6 +448,44 @@ export default function AdminTasksPage() { // ---- 渲染 ---- const modalTask = modalTarget?.task ?? null; + // 按当前列顺序渲染的列 + const orderedColumns = columnOrder + .map((key) => columns.find((c) => c.key === key)) + .filter((c): c is ColumnDef => !!c); + + // 列筛选面板内容 + function renderFilterPanel(col: ColumnDef) { + if (col.filterType === "text") { + return ( +
+ setTextFilters((prev) => ({ ...prev, [col.key]: e.target.value }))} + allowClear + /> +
+ ); + } + // 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(); + return ( +
+ {options.length === 0 ? ( +
无可用选项
+ ) : options.map((opt) => ( + + ))} +
+ ); + } + return (
{/* ---- 标题 ---- */} @@ -286,21 +545,34 @@ export default function AdminTasksPage() { - {/* 状态筛选 Tabs */} + {/* 状态筛选 Tabs(多选) */}
- {STATUS_TABS.map((tab) => ( - - ))} + + {STATUS_TABS.map((tab) => { + const active = statusFilters.has(tab.key); + return ( + + ); + })}
@@ -377,17 +649,48 @@ export default function AdminTasksPage() { {/* 订单展开内容 */} {isOpen && (
- {/* 表头 */} -
-
产品身份证
-
序列号
-
规格型号
-
宏观状态
-
任务状态
-
当前位置
-
最新动态
-
创建时间
-
操作
+ {/* 表头(可排序 / 可筛选 / 可拖拽换列) */} +
+ {orderedColumns.map((col) => { + const sorted = sort?.key === col.key; + const hasFilter = col.filterType === "text" + ? !!(textFilters[col.key] || "").trim() + : !!enumFilters[col.key]?.size; + return ( +
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}` }} + > + 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" ? : ) + : + )} + + {col.filterType && ( + + + + )} +
+ ); + })}
{/* 产品行 */} @@ -395,53 +698,15 @@ export default function AdminTasksPage() { const productExpanded = activeTreeProductId === p.serial_number; const isTreeLoading = treeLoading[p.serial_number]; const tree = taskTrees[p.serial_number]; - // 综合状态:优先流转树状态,兜底产品状态 - // 单一数据源:后端预计算 macro_status,兜底产品 status - const currentStatus = p.macro_status || p.status; - const statusCfg = getStatusConfig(currentStatus); return (
-
-
setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}
-
{p.external_serial || "—"}
-
{p.spec_model || p.material_name || p.material_id || "—"}
-
{p.overall_status || "—"}
-
{statusCfg.label}
-
{p.current_location_id === "virtual_warehouse" ? (🏭 仓库) : (p.current_location_name || p.current_location_id || "—")}
- {/* 最新动态 */} -
- {p.latest_record_time ? ( - -
-
{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
-
- {p.latest_record_has_images && 📷} - - {p.latest_record_assignee_name && {p.latest_record_assignee_name}: } - {p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")} - -
-
-
- ) : } -
-
{new Date(p.created_at).toLocaleDateString("zh-CN")}
-
- -
+
+ {orderedColumns.map((col) => ( +
+ {col.render(p)} +
+ ))}
{/* 展开的流转树 — 卡片堆叠视图 */}