refactor: 产品管理页全面升级—分组折叠面板+搜索/状态过滤+序列号列+与任务全景体验一致

This commit is contained in:
2026-08-10 11:07:53 +08:00
parent 60af3b9998
commit 2eed7db82d

View File

@ -1,20 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import { import {
Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X,
Package, Hash, Tag, MapPin, Clock, Pencil, Trash2, Save, AlertTriangle, Package, Hash, Tag, MapPin, Clock, Pencil, Trash2, Save, AlertTriangle,
Search, ChevronDown, ChevronRight, Warehouse, Barcode,
} from "lucide-react"; } from "lucide-react";
import api from "../../services/api"; import api from "../../services/api";
import type { ProductResponse } from "../../types/admin"; import type { ProductResponse } from "../../types/admin";
import CreateProductDialog from "./CreateProductDialog"; import CreateProductDialog from "./CreateProductDialog";
import { import {
getLabelPreview, getLabelPreview, executePrint, type LabelPreviewRequest,
executePrint,
type LabelPreviewRequest,
} from "../../services/printApi"; } from "../../services/printApi";
import { useToast } from "../../components/ui/Toast"; import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
const QR_BASE = "/api/v1/products/qrcode"; const QR_BASE = "/api/v1/products/qrcode";
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
];
interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; }
export default function AdminProductsPage() { export default function AdminProductsPage() {
const { toast } = useToast(); const { toast } = useToast();
const [products, setProducts] = useState<ProductResponse[]>([]); const [products, setProducts] = useState<ProductResponse[]>([]);
@ -22,20 +32,26 @@ export default function AdminProductsPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
// 打印弹窗 // 搜索 & 筛选 & 分组
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [groupBy, setGroupBy] = useState<"order" | "device">("order");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 打印
const [printTarget, setPrintTarget] = useState<ProductResponse | null>(null); const [printTarget, setPrintTarget] = useState<ProductResponse | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [printLoading, setPrintLoading] = useState(false); const [printLoading, setPrintLoading] = useState(false);
const [printCopies, setPrintCopies] = useState(1); const [printCopies, setPrintCopies] = useState(1);
const [printing, setPrinting] = useState(false); const [printing, setPrinting] = useState(false);
// 编辑弹窗 // 编辑
const [editTarget, setEditTarget] = useState<ProductResponse | null>(null); const [editTarget, setEditTarget] = useState<ProductResponse | null>(null);
const [editOrderNo, setEditOrderNo] = useState(""); const [editOrderNo, setEditOrderNo] = useState("");
const [editExternalSerial, setEditExternalSerial] = useState(""); const [editExternalSerial, setEditExternalSerial] = useState("");
const [editSaving, setEditSaving] = useState(false); const [editSaving, setEditSaving] = useState(false);
// 删除确认 + 防呆 // 删除防呆
const [deleteTarget, setDeleteTarget] = useState<ProductResponse | null>(null); const [deleteTarget, setDeleteTarget] = useState<ProductResponse | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [countdown, setCountdown] = useState(5); const [countdown, setCountdown] = useState(5);
@ -43,176 +59,174 @@ export default function AdminProductsPage() {
const canConfirmDelete = countdown <= 0 && serialInput === deleteTarget?.serial_number; const canConfirmDelete = countdown <= 0 && serialInput === deleteTarget?.serial_number;
async function loadProducts() { async function loadProducts() {
setLoading(true); setLoading(true); setError(null);
setError(null);
try { try {
const { data } = await api.get<ProductResponse[]>("/products/"); const { data } = await api.get<ProductResponse[]>("/products/", {
params: { limit: 1000, ...(keyword.trim() ? { keyword: keyword.trim() } : {}) },
});
setProducts(data); setProducts(data);
} catch { } catch { setError("加载产品列表失败"); }
setError("加载产品列表失败,请检查后端服务"); finally { setLoading(false); }
} finally {
setLoading(false);
}
} }
useEffect(() => { loadProducts(); }, []); useEffect(() => { loadProducts(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) { e?.preventDefault(); setExpandedGroups(new Set()); loadProducts(); }
// ---- 分组 + 状态过滤 ----
const productGroups = useMemo<ProductGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilter) {
const s = (p.macro_status || p.status).toUpperCase();
if (s !== 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(([groupKey, prods]) => ({
groupKey, products: prods,
allInWarehouse: prods.every(p => p.current_location_id === "virtual_warehouse"),
}));
}, [products, statusFilter, groupBy]);
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) { 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: any) { toast(err?.response?.data?.detail ?? "生成预览失败", "error"); setPrintTarget(null); } finally { setPrintLoading(false); } }
setPrintTarget(product); 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: any) { toast(err?.response?.data?.detail ?? "打印失败", "error"); } finally { setPrinting(false); } }
setPreviewUrl(null);
setPrintCopies(1);
setPrintLoading(true);
try {
const payload: LabelPreviewRequest = {
serial_number: product.serial_number,
material_name: product.material_name ?? product.material_id ?? "",
spec_model: product.spec_model ?? "",
order_no: product.order_no ?? "",
};
setPreviewUrl(await getLabelPreview(payload));
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "生成预览失败", "error");
setPrintTarget(null);
} finally {
setPrintLoading(false);
}
}
async function handleConfirmPrint() {
if (!printTarget) return;
setPrinting(true);
try {
const result = await executePrint({
serial_number: printTarget.serial_number,
material_name: printTarget.material_name ?? printTarget.material_id ?? "",
spec_model: printTarget.spec_model ?? "",
order_no: printTarget.order_no ?? "",
copies: printCopies,
});
toast(result.message, "success");
setPrintTarget(null);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "打印失败", "error");
} finally { setPrinting(false); }
}
// ---- 编辑 ---- // ---- 编辑 ----
function openEdit(product: ProductResponse) { function openEdit(p: ProductResponse) { setEditTarget(p); setEditOrderNo(p.order_no ?? ""); setEditExternalSerial(p.external_serial ?? ""); }
setEditTarget(product); 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: any) { toast(err?.response?.data?.detail ?? "保存失败", "error"); } finally { setEditSaving(false); } }
setEditOrderNo(product.order_no ?? "");
setEditExternalSerial(product.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: any) {
toast(err?.response?.data?.detail ?? "保存失败", "error");
} finally { setEditSaving(false); }
}
// ---- 删除 ---- // ---- 删除 ----
// 🔧 倒计时:打开删除弹窗时启动
useEffect(() => { useEffect(() => {
if (!deleteTarget) { setCountdown(5); setSerialInput(""); return; } if (!deleteTarget) { setCountdown(5); setSerialInput(""); return; }
setCountdown(5); setSerialInput(""); setCountdown(5); setSerialInput("");
const timer = setInterval(() => setCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000); const timer = setInterval(() => setCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [deleteTarget?.id]); // eslint-disable-line react-hooks/exhaustive-deps }, [deleteTarget?.id]); // eslint-disable-line
function confirmDelete(product: ProductResponse) { function confirmDelete(p: ProductResponse) { setDeleteTarget(p); }
setDeleteTarget(product); async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "删除失败", "error"); } finally { setDeleting(false); } }
}
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(`/products/${deleteTarget.id}`);
toast("已删除", "success");
setDeleteTarget(null);
loadProducts();
} catch (err: any) {
toast(err?.response?.data?.detail ?? "删除失败", "error");
} finally { setDeleting(false); }
}
return ( return (
<div> <div>
{/* 标题栏 */} {/* 标题栏 */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <div><h2 className="text-xl font-bold text-gray-800">产品管理</h2><p className="mt-1 text-sm text-gray-500">查看所有产品身份证并生成二维码</p></div>
<h2 className="text-xl font-bold text-gray-800">产品管理</h2>
<p className="mt-1 text-sm text-gray-500">查看所有产品身份证并生成二维码用于打印标签</p>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<a href="/admin/print-config" className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 hover:bg-gray-50" title="打印机设置"> <a href="/admin/print-config" className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 hover:bg-gray-50"><Settings className="h-4 w-4" /></a>
<Settings className="h-4 w-4" /> <button onClick={() => setShowCreate(true)} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"><Plus className="h-4 w-4" />创建产品</button>
</a> <button onClick={loadProducts} className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />刷新</button>
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"> </div>
<Plus className="h-4 w-4" />创建产品 </div>
</button>
<button onClick={loadProducts} className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"> {/* 搜索 + 分组 + 状态过滤(移植自 AdminTasksPage) */}
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />刷新 <div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 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>
<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-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" />
</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>
</form>
<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>
</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>} {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 && <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 && ( {!loading && products.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400"> <div className="flex flex-col items-center justify-center py-20 text-gray-400">
<QrCode className="mb-3 h-12 w-12" /><p>暂无产品数据</p><p className="mt-1 text-sm">创建产品后将在此显示二维码</p> <QrCode className="mb-3 h-12 w-12" /><p>暂无产品数据</p>
</div> </div>
)} )}
{/* 产品卡片网格 */} {/* 🚀 分组折叠面板 */}
{!loading && products.length > 0 && ( {!loading && productGroups.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="space-y-3">
{products.map((p) => ( {productGroups.map(group => {
<div key={p.id} className="group relative flex flex-col rounded-xl bg-white shadow-sm transition-shadow hover:shadow-md"> const isOpen = expandedGroups.has(group.groupKey);
{/* 操作图标 — hover 显示 */} return (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div key={group.groupKey} className="overflow-hidden rounded-xl bg-white shadow-sm">
<button onClick={() => openEdit(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600" title="编辑"> <button onClick={() => toggleGroup(group.groupKey)} className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors">
<Pencil className="h-3.5 w-3.5" /> {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.groupKey === "未绑定订单" || group.groupKey === "未命名设备"
? "📋 未分类"
: groupBy === "device" ? `⚙️ 设备: ${group.groupKey}` : `📦 订单: ${group.groupKey}`}
</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>
)}
</button> </button>
<button onClick={() => confirmDelete(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* 二维码 + ID */} {/* 产品卡片网格 */}
<div className="flex flex-col items-center px-4 pt-5 pb-3"> {isOpen && (
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" /> <div className="border-t border-gray-100 px-5 py-4">
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">{p.serial_number}</p> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{group.products.map(p => (
<div key={p.id} className="group relative flex flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
<button onClick={() => openEdit(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600"><Pencil className="h-3.5 w-3.5" /></button>
<button onClick={() => confirmDelete(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
<div className="flex flex-col items-center px-4 pt-5 pb-3">
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">{p.serial_number}</p>
</div>
<div className="flex-1 space-y-2 border-t border-gray-50 px-4 py-3">
<InfoRow icon={Package} label="物料名称" value={p.material_name || p.material_id || "—"} />
<InfoRow icon={Barcode} label="产品序列号" value={p.external_serial || "—"} />
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
</div>
<div className="border-t border-gray-50 px-4 py-3">
<button onClick={() => handleOpenPrint(p)} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
<Printer className="h-3.5 w-3.5" />打印标签
</button>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
);
{/* 信息区 */} })}
<div className="flex-1 space-y-2 border-t border-gray-50 px-4 py-3">
<InfoRow icon={Package} label="物料名称" value={p.material_name || p.material_id || "—"} />
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
</div>
{/* 打印 */}
<div className="border-t border-gray-50 px-4 py-3">
<button onClick={() => handleOpenPrint(p)} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
<Printer className="h-3.5 w-3.5" />打印标签
</button>
</div>
</div>
))}
</div> </div>
)} )}
@ -223,32 +237,11 @@ export default function AdminProductsPage() {
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !printing && setPrintTarget(null)} /> <div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !printing && setPrintTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl"> <div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800">标签打印预览</h3><button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-4 w-4" /></button></div>
<h3 className="text-base font-bold text-gray-800">标签打印预览</h3> <div className="mb-4 flex justify-center">{printLoading || !previewUrl ? <div className="flex h-48 w-full items-center justify-center rounded-lg bg-gray-50"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div> : <img src={previewUrl} alt="预览" className="max-h-64 rounded-lg border border-gray-200" />}</div>
<button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50"><X className="h-4 w-4" /></button>
</div>
<div className="mb-4 flex justify-center">
{printLoading || !previewUrl ? (
<div className="flex h-48 w-full items-center justify-center rounded-lg bg-gray-50"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div>
) : (
<img src={previewUrl} alt="标签预览" className="max-h-64 rounded-lg border border-gray-200" />
)}
</div>
<p className="mb-4 text-center font-mono text-sm font-bold tracking-wider text-gray-700">{printTarget.serial_number}</p> <p className="mb-4 text-center font-mono text-sm font-bold tracking-wider text-gray-700">{printTarget.serial_number}</p>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between"><span className="text-sm text-gray-600">打印份数</span><div className="flex items-center gap-2"><button onClick={() => setPrintCopies(c => Math.max(1, c - 1))} className="rounded border px-2.5 py-1 text-sm">−</button><span className="w-8 text-center text-sm font-semibold">{printCopies}</span><button onClick={() => setPrintCopies(c => Math.min(100, c + 1))} className="rounded border px-2.5 py-1 text-sm">+</button></div></div>
<span className="text-sm text-gray-600">打印份数</span> <div className="flex justify-end gap-2"><button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleConfirmPrint} disabled={printing || printLoading} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{printing && <Loader2 className="h-3.5 w-3.5 animate-spin" />}确认打印</button></div>
<div className="flex items-center gap-2">
<button onClick={() => setPrintCopies((c) => Math.max(1, c - 1))} className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50">−</button>
<span className="w-8 text-center text-sm font-semibold">{printCopies}</span>
<button onClick={() => setPrintCopies((c) => Math.min(100, c + 1))} className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50">+</button>
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">取消</button>
<button onClick={handleConfirmPrint} disabled={printing || printLoading} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{printing && <Loader2 className="h-3.5 w-3.5 animate-spin" />}确认打印
</button>
</div>
</div> </div>
</div> </div>
)} )}
@ -258,81 +251,26 @@ export default function AdminProductsPage() {
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} /> <div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl"> <div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800">编辑产品</h3><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
<h3 className="text-base font-bold text-gray-800">编辑产品</h3>
<button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50"><X className="h-4 w-4" /></button>
</div>
<p className="mb-4 font-mono text-sm text-gray-500">产品ID: {editTarget.serial_number}</p> <p className="mb-4 font-mono text-sm text-gray-500">产品ID: {editTarget.serial_number}</p>
<div className="space-y-4"> <div className="space-y-4">
<div> <div><label className="mb-1 block text-sm font-medium text-gray-700">订单编号</label><input value={editOrderNo} onChange={e => 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" /></div>
<label className="mb-1 block text-sm font-medium text-gray-700">订单编号</label> <div><label className="mb-1 block text-sm font-medium text-gray-700">产品序列号</label><input value={editExternalSerial} onChange={e => 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" /></div>
<input value={editOrderNo} onChange={(e) => setEditOrderNo(e.target.value)} placeholder="请输入订单编号" className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">产品序列号</label>
<input value={editExternalSerial} onChange={(e) => setEditExternalSerial(e.target.value)} placeholder="请输入产品序列号" className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" />
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">取消</button>
<button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
<Save className="h-3.5 w-3.5" />保存
</button>
</div> </div>
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}<Save className="h-3.5 w-3.5" />保存</button></div>
</div> </div>
</div> </div>
)} )}
{/* 删除确认弹窗 */} {/* 删除确认弹窗 */}
{/* 🔧 删除确认 — 5秒倒计时 + 序列号二次确认 */}
{deleteTarget && ( {deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !deleting && setDeleteTarget(null)} /> <div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !deleting && setDeleteTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl"> <div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3"><div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100"><AlertTriangle className="h-5 w-5 text-red-600" /></div><div><h3 className="text-base font-bold text-gray-800">危险操作</h3><p className="text-xs text-gray-500">删除产品及其全部关联数据</p></div></div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100"> <p className="mb-4 text-sm text-gray-600">将永久删除 <span className="font-mono font-bold">{deleteTarget.serial_number}</span> 及其所有任务、记录和二维码。</p>
<AlertTriangle className="h-5 w-5 text-red-600" /> <div className="mb-4"><label className="mb-1 block text-xs font-medium text-gray-600">请输入产品身份证以确认删除</label><input value={serialInput} onChange={e => 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} /></div>
</div> <div className="flex items-center justify-end gap-2"><button onClick={() => setDeleteTarget(null)} disabled={deleting} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleDelete} disabled={!canConfirmDelete || deleting} className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50">{deleting ? <><Loader2 className="h-3.5 w-3.5 animate-spin" />删除中...</> : canConfirmDelete ? <><Trash2 className="h-3.5 w-3.5" />确认删除</> : <>确认删除 ({countdown}s)</>}</button></div>
<div>
<h3 className="text-base font-bold text-gray-800">危险操作</h3>
<p className="text-xs text-gray-500">删除产品及其全部关联数据</p>
</div>
</div>
<p className="mb-4 text-sm text-gray-600">
将永久删除 <span className="font-mono font-bold">{deleteTarget.serial_number}</span> 及其所有任务、记录和二维码。此操作不可恢复。
</p>
{/* 步骤1:输入序列号确认 */}
<div className="mb-4">
<label className="mb-1 block text-xs font-medium text-gray-600">
请输入产品身份证以确认删除
</label>
<input
value={serialInput}
onChange={(e) => 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 focus:ring-2 focus:ring-red-100"
disabled={deleting}
/>
</div>
{/* 步骤2:5秒倒计时 + 确认按钮 */}
<div className="flex items-center justify-end gap-2">
<button onClick={() => setDeleteTarget(null)} disabled={deleting}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">取消</button>
<button onClick={handleDelete} disabled={!canConfirmDelete || deleting}
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50">
{deleting ? <><Loader2 className="h-3.5 w-3.5 animate-spin" />删除中...</>
: canConfirmDelete ? <><Trash2 className="h-3.5 w-3.5" />确认删除</>
: <>确认删除 ({countdown}s)</>}
</button>
</div>
</div> </div>
</div> </div>
)} )}
@ -341,11 +279,5 @@ export default function AdminProductsPage() {
} }
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) { function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return ( return <div className="flex items-center gap-2 text-xs"><Icon className="h-3 w-3 shrink-0 text-gray-400" /><span className="shrink-0 text-gray-400">{label}</span><span className="truncate font-medium text-gray-700">{value}</span></div>;
<div className="flex items-center gap-2 text-xs">
<Icon className="h-3 w-3 shrink-0 text-gray-400" />
<span className="shrink-0 text-gray-400">{label}</span>
<span className="truncate font-medium text-gray-700">{value}</span>
</div>
);
} }