import { useEffect, useState, useMemo } from "react"; import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Package, Hash, Tag, MapPin, Clock, Timer, CalendarDays, Pencil, Trash2, Save, AlertTriangle, Search, ChevronDown, ChevronRight, Warehouse, Barcode, } from "lucide-react"; import api from "../../services/api"; import type { ProductResponse } from "../../types/admin"; import CreateProductDialog from "./CreateProductDialog"; import { getLabelPreview, executePrint, } from "../../services/printApi"; import { useToast } from "../../components/ui/Toast"; import { useAuth } from "../../contexts/AuthContext"; import { getStatusConfig, lifecycleBadge, isAdminRole } from "../../constants/task"; import { extractErrorMessage } from "../../utils/errorMessage"; const QR_BASE = "/api/v1/products/qrcode"; const STATUS_TABS = [ { key: "PENDING", label: "待接收" }, { key: "WIP", label: "进行中" }, { key: "COMPLETED", label: "已完成" }, { key: "ARCHIVED", label: "已入库" }, { key: "OUTBOUND", label: "已出库" }, ]; interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; } export default function AdminProductsPage() { const { toast } = useToast(); const { user: authUser } = useAuth(); const isAdmin = isAdminRole(authUser?.role); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showCreate, setShowCreate] = useState(false); // 搜索 & 筛选 & 分组 const [keyword, setKeyword] = useState(""); const [statusFilters, setStatusFilters] = useState>(new Set()); const [groupBy, setGroupBy] = useState<"order" | "device">("device"); const [expandedGroups, setExpandedGroups] = useState>(new Set()); // 打印 const [printTarget, setPrintTarget] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [printLoading, setPrintLoading] = useState(false); const [printCopies, setPrintCopies] = useState(1); const [printing, setPrinting] = useState(false); // 收口(入库/出库,仅管理员,需倒计时+输入身份证确认) const [finalizeTarget, setFinalizeTarget] = useState(null); const [finalizeStatus, setFinalizeStatus] = useState<"已入库" | "已出库">("已入库"); const [finalizeNote, setFinalizeNote] = useState(""); const [finalizeSerialInput, setFinalizeSerialInput] = useState(""); const [finalizeCountdown, setFinalizeCountdown] = useState(5); const [finalizing, setFinalizing] = useState(false); const canConfirmFinalize = !!finalizeTarget && finalizeCountdown <= 0 && finalizeSerialInput === finalizeTarget.serial_number; // 编辑 const [editTarget, setEditTarget] = useState(null); const [editOrderNo, setEditOrderNo] = useState(""); const [editExternalSerial, setEditExternalSerial] = useState(""); const [editSaving, setEditSaving] = useState(false); // 删除防呆 const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [countdown, setCountdown] = useState(5); const [serialInput, setSerialInput] = useState(""); const canConfirmDelete = countdown <= 0 && serialInput === deleteTarget?.serial_number; async function loadProducts() { setLoading(true); setError(null); try { const { data } = await api.get("/products/", { params: { limit: 1000, ...(keyword.trim() ? { keyword: keyword.trim() } : {}) }, }); setProducts(data); } catch { setError("加载产品列表失败"); } finally { setLoading(false); } } useEffect(() => { loadProducts(); }, []); // eslint-disable-line react-hooks/exhaustive-deps function handleSearch(e?: React.FormEvent) { e?.preventDefault(); loadProducts(); } // ---- 分组 + 状态过滤(多选)---- const productGroups = useMemo(() => { const map = new Map(); for (const p of products) { if (statusFilters.size > 0) { const s = (p.macro_status || p.status).toUpperCase(); if (!statusFilters.has(s)) 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(([groupKey, prods]) => ({ groupKey, products: prods, // 已入库 = MOM 已扫码实收 (macro_status==ARCHIVED),而非仅"在仓库位置" allInWarehouse: prods.every(p => (p.macro_status || p.status).toUpperCase() === "ARCHIVED"), })); }, [products, statusFilters, groupBy]); function toggleStatusFilter(key: string) { setStatusFilters(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } // 🔧 默认全部展开:productGroups 变化时自动展开所有面板 useEffect(() => { setExpandedGroups(new Set(productGroups.map(g => g.groupKey))); }, [productGroups]); function toggleGroup(key: string) { setExpandedGroups(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n; }); } // ---- 打印 ---- async function handleOpenPrint(product: ProductResponse) { /* unchanged */ setPrintTarget(product); setPreviewUrl(null); setPrintCopies(1); setPrintLoading(true); try { setPreviewUrl(await getLabelPreview({ serial_number: product.serial_number, material_name: product.material_name ?? product.material_id ?? "", spec_model: product.spec_model ?? "", order_no: product.order_no ?? "" })); } catch (err: unknown) { toast(extractErrorMessage(err, "生成预览失败"), "error"); setPrintTarget(null); } finally { setPrintLoading(false); } } async function handleConfirmPrint() { if (!printTarget) return; setPrinting(true); try { const r = await executePrint({ serial_number: printTarget.serial_number, material_name: printTarget.material_name ?? "", spec_model: printTarget.spec_model ?? "", order_no: printTarget.order_no ?? "", copies: printCopies }); toast(r.message, "success"); setPrintTarget(null); } catch (err: unknown) { toast(extractErrorMessage(err, "打印失败"), "error"); } finally { setPrinting(false); } } // ---- 编辑 ---- function openEdit(p: ProductResponse) { setEditTarget(p); setEditOrderNo(p.order_no ?? ""); setEditExternalSerial(p.external_serial ?? ""); } async function handleSaveEdit() { if (!editTarget) return; setEditSaving(true); try { await api.patch(`/products/${editTarget.id}`, { order_no: editOrderNo.trim() || null, external_serial: editExternalSerial.trim() || null }); toast("保存成功", "success"); setEditTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "保存失败"), "error"); } finally { setEditSaving(false); } } // ---- 删除 ---- useEffect(() => { if (!deleteTarget) { setCountdown(5); setSerialInput(""); return; } setCountdown(5); setSerialInput(""); const timer = setInterval(() => setCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000); return () => clearInterval(timer); }, [deleteTarget?.id]); // eslint-disable-line function confirmDelete(p: ProductResponse) { setDeleteTarget(p); } async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "删除失败"), "error"); } finally { setDeleting(false); } } // ---- 状态收口(入库 / 出库,管理员)---- // 🛡️ 双重确认倒计时 + 必须输入产品身份证,防误触 useEffect(() => { if (!finalizeTarget) { setFinalizeCountdown(5); setFinalizeSerialInput(""); return; } setFinalizeCountdown(5); setFinalizeSerialInput(""); const timer = setInterval(() => setFinalizeCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000); return () => clearInterval(timer); }, [finalizeTarget?.id]); // eslint-disable-line react-hooks/exhaustive-deps function openFinalize(p: ProductResponse) { setFinalizeTarget(p); setFinalizeStatus("已入库"); setFinalizeNote(""); setFinalizeSerialInput(""); setFinalizeCountdown(5); } async function handleFinalize() { if (!canConfirmFinalize) return; setFinalizing(true); try { await api.post(`/products/scan/${finalizeTarget.serial_number}/finalize`, { status: finalizeStatus, note: finalizeNote.trim() || undefined, }); toast(`已收口为「${finalizeStatus}」`, "success"); setFinalizeTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "操作失败"), "error"); } finally { setFinalizing(false); } } return (
{/* 标题栏 */}

产品管理

查看所有产品身份证并生成二维码

{/* 搜索 + 分组 + 状态过滤(移植自 AdminTasksPage) */}
分组: {(["order", "device"] as const).map(m => ( ))}
setKeyword(e.target.value)} placeholder="搜索产品身份证、业务序列号、订单号、名称、规格型号..." className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" />
{STATUS_TABS.map(tab => { const active = statusFilters.has(tab.key); return ( ); })}
{error &&
{error}
} {loading &&
} {/* 空态 */} {!loading && products.length === 0 && !error && (

暂无产品数据

)} {/* 🚀 分组折叠面板 */} {!loading && productGroups.length > 0 && (
{productGroups.map(group => { const isOpen = expandedGroups.has(group.groupKey); return (
{/* 产品卡片网格 */} {isOpen && (
{group.products.map(p => (
window.open(`/admin/tasks?sn=${p.serial_number}`, "_blank")} className="group relative flex cursor-pointer flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
{`QR-${p.serial_number}`}

{p.serial_number}

{(() => { const cfg = getStatusConfig(p.macro_status || p.status); return cfg.label ? ( {cfg.label} ) : null; })()} {/* 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示 */} {(() => { const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase); return lb ? ( {lb.label} ) : null; })()}
当前人时间 {(() => { 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)}; })()}
{isAdmin && ( )}
))}
)}
); })}
)} setShowCreate(false)} onCreated={loadProducts} /> {/* 打印预览弹窗 */} {printTarget && (
!printing && setPrintTarget(null)} />

标签打印预览

{printLoading || !previewUrl ?
: 预览}

{printTarget.serial_number}

打印份数
{printCopies}
)} {/* 编辑弹窗 */} {editTarget && (
!editSaving && setEditTarget(null)} />

编辑产品

产品ID: {editTarget.serial_number}

setEditOrderNo(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" />
setEditExternalSerial(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" />
)} {/* 收口弹窗(入库/出库,仅管理员) */} {finalizeTarget && (
!finalizing && setFinalizeTarget(null)} />

产品状态收口

{finalizeTarget.serial_number}

当前:{getStatusConfig(finalizeTarget.macro_status || finalizeTarget.status).label || "—"} | 位置:{finalizeTarget.current_location_name || finalizeTarget.current_location_id || "—"}

{(["已入库", "已出库"] as const).map(s => ( ))}

⚠ 收口为最终状态:若该产品仍有进行中/待接收任务,将一并结束为完工。

setFinalizeNote(e.target.value)} placeholder="例如:纠错补录" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none" disabled={finalizing} />
setFinalizeSerialInput(e.target.value)} placeholder={finalizeTarget.serial_number} maxLength={16} disabled={finalizing} className="w-full rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-red-400 focus:outline-none" />

0 ? "text-amber-600" : "text-red-600"}`}> {finalizeCountdown > 0 ? `⏳ ${finalizeCountdown}s 确认等待中:请核对目标状态与产品身份证` : finalizeSerialInput === finalizeTarget.serial_number ? "✅ 已核验,可确认收口" : `⚠ 请完整输入:${finalizeTarget.serial_number}`}

)} {/* 删除确认弹窗 */} {deleteTarget && (
!deleting && setDeleteTarget(null)} />

危险操作

删除产品及其全部关联数据

将永久删除 {deleteTarget.serial_number} 及其所有任务、记录和二维码。

setSerialInput(e.target.value)} placeholder={deleteTarget.serial_number} maxLength={16} className="w-full rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-red-400 focus:outline-none" disabled={deleting} />
)}
); } function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) { return
{label}{value}
; } /** 滞留时长格式化:小时 → 天/小时/分钟 */ 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))}分钟`; } /** 生产总天数:从产品创建(created_at)到现在,向上取整,最少 1 天 */ function formatProductionDays(createdAt: string): string { const days = Math.max(1, Math.ceil((Date.now() - new Date(createdAt).getTime()) / 86400000)); return `${days} 天`; }