feat: 产品管理 — 当前位置显示真实姓名 + 编辑弹窗 + 删除确认

This commit is contained in:
2026-08-07 15:38:01 +08:00
parent d3d501dfeb
commit bc43b5a732
5 changed files with 254 additions and 180 deletions

View File

@ -1,5 +1,8 @@
import { useEffect, useState } from "react";
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Package, Hash, Tag, MapPin, Clock } from "lucide-react";
import {
Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X,
Package, Hash, Tag, MapPin, Clock, Pencil, Trash2, Save, AlertTriangle,
} from "lucide-react";
import api from "../../services/api";
import type { ProductResponse } from "../../types/admin";
import CreateProductDialog from "./CreateProductDialog";
@ -9,7 +12,6 @@ import {
type LabelPreviewRequest,
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
const QR_BASE = "/api/v1/products/qrcode";
@ -20,13 +22,23 @@ export default function AdminProductsPage() {
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
// 打印弹窗状态
// 打印弹窗
const [printTarget, setPrintTarget] = useState<ProductResponse | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [printLoading, setPrintLoading] = useState(false);
const [printCopies, setPrintCopies] = useState(1);
const [printing, setPrinting] = useState(false);
// 编辑弹窗
const [editTarget, setEditTarget] = useState<ProductResponse | null>(null);
const [editOrderNo, setEditOrderNo] = useState("");
const [editExternalSerial, setEditExternalSerial] = useState("");
const [editSaving, setEditSaving] = useState(false);
// 删除确认
const [deleteTarget, setDeleteTarget] = useState<ProductResponse | null>(null);
const [deleting, setDeleting] = useState(false);
async function loadProducts() {
setLoading(true);
setError(null);
@ -40,18 +52,14 @@ export default function AdminProductsPage() {
}
}
useEffect(() => {
loadProducts();
}, []);
// ---- 打印标签 ----
useEffect(() => { loadProducts(); }, []);
// ---- 打印 ----
async function handleOpenPrint(product: ProductResponse) {
setPrintTarget(product);
setPreviewUrl(null);
setPrintCopies(1);
setPrintLoading(true);
try {
const payload: LabelPreviewRequest = {
serial_number: product.serial_number,
@ -59,8 +67,7 @@ export default function AdminProductsPage() {
spec_model: product.spec_model ?? "",
order_no: product.order_no ?? "",
};
const url = await getLabelPreview(payload);
setPreviewUrl(url);
setPreviewUrl(await getLabelPreview(payload));
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "生成预览失败", "error");
setPrintTarget(null);
@ -84,9 +91,48 @@ export default function AdminProductsPage() {
setPrintTarget(null);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "打印失败", "error");
} finally {
setPrinting(false);
}
} finally { setPrinting(false); }
}
// ---- 编辑 ----
function openEdit(product: ProductResponse) {
setEditTarget(product);
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); }
}
// ---- 删除 ----
function confirmDelete(product: ProductResponse) {
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); }
}
return (
@ -95,203 +141,166 @@ export default function AdminProductsPage() {
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">
</p>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<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 transition-colors 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" title="打印机设置">
<Settings className="h-4 w-4" />
</a>
<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 transition-colors hover:bg-blue-700"
>
<Plus 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>
<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 transition-colors hover:bg-gray-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
<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>
</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>
)}
{/* 空状态 */}
{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">
<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><p className="mt-1 text-sm"></p>
</div>
)}
{/* 🚀 响应式网格 — 稳定不拉伸 */}
{/* 产品卡片网格 */}
{!loading && products.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">
{products.map((p) => {
const statusCfg = getStatusConfig(p.status);
return (
<div
key={p.id}
className="flex flex-col rounded-xl bg-white shadow-sm transition-shadow hover:shadow-md"
>
{/* 顶部:二维码 + 产品ID */}
<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={Hash} label="规格型号" value={p.spec_model || "—"} />
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
<InfoRow
icon={MapPin}
label="当前位置"
value={
p.current_location_id === "virtual_warehouse"
? "🏭 仓库"
: p.current_location_id || "—"
}
/>
<InfoRow
icon={Clock}
label="创建时间"
value={new Date(p.created_at).toLocaleDateString("zh-CN")}
/>
{p.overall_status && (
<div className="flex items-center gap-2 text-xs">
<span className="shrink-0 text-gray-400"></span>
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">
{p.overall_status}
</span>
</div>
)}
</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 transition-colors hover:bg-blue-100 hover:border-blue-300"
>
<Printer className="h-3.5 w-3.5" />
</button>
</div>
{products.map((p) => (
<div key={p.id} className="group relative flex flex-col rounded-xl bg-white shadow-sm transition-shadow hover:shadow-md">
{/* 操作图标 — hover 显示 */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<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="编辑">
<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" 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">
<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={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>
)}
<CreateProductDialog
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={loadProducts}
/>
<CreateProductDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={loadProducts} />
{/* 打印预览弹窗 */}
{printTarget && (
<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="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 disabled:opacity-50"
>
<X className="h-4 w-4" />
</button>
<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>
<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"
/>
<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">
<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 border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
>
</button>
<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>
<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>
)}
{/* 编辑弹窗 */}
{editTarget && (
<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="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">
<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>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700"></label>
<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="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"
>
<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>
<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" />}
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{deleteTarget && (
<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="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="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>
<p className="mb-2 text-sm text-gray-600">
<span className="font-mono font-bold">{deleteTarget.serial_number}</span>
</p>
<p className="mb-6 text-xs text-red-500"></p>
<div className="flex 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={deleting} className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
{deleting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
@ -301,16 +310,7 @@ 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 (
<div className="flex items-center gap-2 text-xs">
<Icon className="h-3 w-3 shrink-0 text-gray-400" />

View File

@ -13,6 +13,7 @@ export interface ProductResponse {
material_type: string | null;
parent_product_id: string | null;
current_location_id: string | null;
current_location_name: string | null;
overall_status: string | null;
status: string;
created_at: string;