Files
track/frontend/src/pages/admin/AdminTasksPage.tsx

522 lines
23 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,
} from "lucide-react";
import { Tooltip } 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 { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse, TaskResponse } 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: "已入库" },
];
interface OrderGroup {
orderNo: string;
products: ProductResponse[];
allInWarehouse: boolean;
}
export default function AdminTasksPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const [searchParams] = useSearchParams();
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
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弹窗
// 从看板跳转: ?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) toggleProductTree(found.serial_number);
});
} else {
loadProducts(keyword);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
setExpandedProducts(new Set());
setTaskTrees({});
loadProducts(keyword);
}
// ---- 按订单/设备分组 + 本地状态过滤 ----
const orderGroups = useMemo<OrderGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilter) {
const currentStatus = (p.macro_status || p.status).toUpperCase();
if (currentStatus !== statusFilter) 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]) => ({
orderNo,
products: prods,
allInWarehouse: prods.every(
(p) => p.current_location_id === "virtual_warehouse"
),
}));
}, [products, statusFilter, groupBy]);
// 🔧 默认全部展开
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;
});
}
// ---- 排他展开(手风琴模式) ----
async function toggleProductTree(serialNumber: string) {
// 点击已展开的树 → 收起
if (activeTreeProductId === serialNumber) {
setActiveTreeProductId(null);
return;
}
// 展开新的 → 自动收起旧的
setActiveTreeProductId(serialNumber);
if (!taskTrees[serialNumber]) {
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
scanProduct(serialNumber)
.then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result })))
.catch((err: any) => toast(err?.response?.data?.detail ?? err?.message ?? "加载任务树失败", "error"))
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
}
}
// ---- 任务操作 ----
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(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason);
toast("任务已驳回", "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error");
} finally {
setSubmitting(false);
}
}
async function handleTransfer(
nextTaskName: string,
assignees: string[],
note: string,
) {
if (!modalTarget) return;
setSubmitting(true);
try {
const result = await transferTask(
modalTarget.task.id, assignees, nextTaskName, note || undefined,
);
toast(result.message, "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error");
} finally {
setSubmitting(false);
}
}
// ---- 渲染 ----
const modalTask = modalTarget?.task ?? null;
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 gap-1">
<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>
</form>
{/* 状态筛选 Tabs */}
<div className="mt-3 flex gap-1.5 flex-wrap">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilter === tab.key
? "bg-blue-600 text-white"
: "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.allInWarehouse && (
<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(16, minmax(0, 1fr))" }}>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
</div>
{/* 产品行 */}
{group.products.map((p) => {
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 (
<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(16, minmax(0, 1fr))" }}>
<div className="col-span-2 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>
<div className="col-span-1 font-mono text-xs text-gray-600 truncate" title={p.external_serial || p.serial_number}>{p.external_serial || (p.serial_number ? "…" + p.serial_number.slice(-6) : "—")}</div>
<div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
<div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div>
<div className="col-span-1 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>
{/* 最新动态 */}
<div className="col-span-2 text-xs">
{p.latest_record_time ? (
<Tooltip title={(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_content || (p.latest_record_has_images ? "图片记录" : "—")}</span>
</div>
</div>
</Tooltip>
) : <span className="text-gray-300"></span>}
</div>
<div className="col-span-2 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
<div className="col-span-2">
<button
onClick={() => toggleProductTree(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" />
) : productExpanded ? (
<X className="h-3 w-3" />
) : (
<GitBranch className="h-3 w-3" />
)}
{productExpanded ? "收起" : "流转树"}
</button>
</div>
</div>
{/* 展开的流转树 — 卡片堆叠视图 */}
{productExpanded && tree && (
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
<GitBranch className="h-3.5 w-3.5" />
{p.serial_number}
</h4>
{tree.task_tree && tree.task_tree.length > 0 ? (
<TaskFlowView
tasks={tree.task_tree}
onAction={setModalTarget}
currentUser={currentUser}
assigneeNames={tree.assignee_names}
/>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-xs text-gray-400"></p>
</div>
)}
</div>
)}
{productExpanded && isTreeLoading && (
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
<p className="mt-2 text-xs text-gray-400">...</p>
</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>
)}
{/* ---- 弹窗 ---- */}
<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}
/>
</div>
);
}