feat: 产品管理页新增状态收口按钮,倒计时+输入产品身份证双重确认防误触

This commit is contained in:
2026-09-07 13:43:09 +08:00
parent 70c8357bc0
commit 2925eb8e0b

View File

@ -12,6 +12,7 @@ import {
getLabelPreview, executePrint, getLabelPreview, executePrint,
} from "../../services/printApi"; } from "../../services/printApi";
import { useToast } from "../../components/ui/Toast"; import { useToast } from "../../components/ui/Toast";
import { useAuth } from "../../contexts/AuthContext";
import { getStatusConfig } from "../../constants/task"; import { getStatusConfig } from "../../constants/task";
const QR_BASE = "/api/v1/products/qrcode"; const QR_BASE = "/api/v1/products/qrcode";
@ -28,6 +29,8 @@ interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWar
export default function AdminProductsPage() { export default function AdminProductsPage() {
const { toast } = useToast(); const { toast } = useToast();
const { user: authUser } = useAuth();
const isAdmin = authUser?.role === "SUPER_ADMIN" || authUser?.role === "SUPERVISOR";
const [products, setProducts] = useState<ProductResponse[]>([]); const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -46,6 +49,15 @@ export default function AdminProductsPage() {
const [printCopies, setPrintCopies] = useState(1); const [printCopies, setPrintCopies] = useState(1);
const [printing, setPrinting] = useState(false); const [printing, setPrinting] = useState(false);
// 收口(入库/出库,仅管理员,需倒计时+输入身份证确认)
const [finalizeTarget, setFinalizeTarget] = useState<ProductResponse | null>(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<ProductResponse | null>(null); const [editTarget, setEditTarget] = useState<ProductResponse | null>(null);
const [editOrderNo, setEditOrderNo] = useState(""); const [editOrderNo, setEditOrderNo] = useState("");
@ -131,6 +143,34 @@ export default function AdminProductsPage() {
function confirmDelete(p: ProductResponse) { setDeleteTarget(p); } 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: 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); } }
// ---- 状态收口(入库 / 出库,管理员)----
// 🛡️ 双重确认倒计时 + 必须输入产品身份证,防误触
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: any) {
toast(err?.response?.data?.detail ?? "操作失败", "error");
} finally {
setFinalizing(false);
}
}
return ( return (
<div> <div>
{/* 标题栏 */} {/* 标题栏 */}
@ -262,7 +302,12 @@ export default function AdminProductsPage() {
<InfoRow icon={CalendarDays} label="生产总天数(工作日)" value={p.production_days_workdays != null ? `${p.production_days_workdays} 天` : "—"} /> <InfoRow icon={CalendarDays} label="生产总天数(工作日)" value={p.production_days_workdays != null ? `${p.production_days_workdays} 天` : "—"} />
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} /> <InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
</div> </div>
<div className="border-t border-gray-50 px-4 py-3"> <div className="space-y-2 border-t border-gray-50 px-4 py-3">
{isAdmin && (
<button onClick={(e) => { e.stopPropagation(); openFinalize(p); }} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-indigo-200 bg-indigo-50 py-2 text-xs font-medium text-indigo-700 hover:bg-indigo-100 hover:border-indigo-300">
🏷 收口:入库 / 出库
</button>
)}
<button onClick={(e) => { e.stopPropagation(); 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"> <button onClick={(e) => { e.stopPropagation(); 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" />打印标签 <Printer className="h-3.5 w-3.5" />打印标签
</button> </button>
@ -310,6 +355,52 @@ export default function AdminProductsPage() {
</div> </div>
)} )}
{/* 收口弹窗(入库/出库,仅管理员) */}
{finalizeTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !finalizing && setFinalizeTarget(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={() => setFinalizeTarget(null)} disabled={finalizing} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-4 w-4" /></button></div>
<p className="mb-1 font-mono text-sm font-bold text-gray-700">{finalizeTarget.serial_number}</p>
<p className="mb-4 text-xs text-gray-500">当前:{getStatusConfig(finalizeTarget.macro_status || finalizeTarget.status).label || "—"} | 位置:{finalizeTarget.current_location_name || finalizeTarget.current_location_id || "—"}</p>
<div className="mb-4 space-y-2">
{(["已入库", "已出库"] as const).map(s => (
<label key={s} onClick={(e) => e.stopPropagation()} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm ${finalizeStatus === s ? "border-indigo-400 bg-indigo-50 text-indigo-700" : "border-gray-200 text-gray-700 hover:bg-gray-50"}`}>
<input type="radio" name="finalizeStatus" checked={finalizeStatus === s} onChange={() => setFinalizeStatus(s)} className="accent-indigo-600" />
{s === "已入库" ? "入库(仓库实收 / ARCHIVED)" : "出库(发货 / OUTBOUND)"}
</label>
))}
</div>
<p className="mb-3 text-xs text-amber-600">⚠ 收口为最终状态:若该产品仍有进行中/待接收任务,将一并结束为完工。</p>
<div className="mb-4"><label className="mb-1 block text-xs font-medium text-gray-600">备注(选填)</label><input value={finalizeNote} onChange={(e) => 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} /></div>
<div className="mb-4">
<label className="mb-1 block text-xs font-medium text-gray-600">输入产品身份证以确认</label>
<input
value={finalizeSerialInput}
onChange={(e) => 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"
/>
<p className={`mt-1 text-[11px] ${finalizeCountdown > 0 ? "text-amber-600" : "text-red-600"}`}>
{finalizeCountdown > 0
? `⏳ ${finalizeCountdown}s 确认等待中:请核对目标状态与产品身份证`
: finalizeSerialInput === finalizeTarget.serial_number
? "✅ 已核验,可确认收口"
: `⚠ 请完整输入:${finalizeTarget.serial_number}`}
</p>
</div>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setFinalizeTarget(null)} disabled={finalizing} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button>
<button onClick={handleFinalize} disabled={!canConfirmFinalize || finalizing} className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50">
{finalizing ? (<><Loader2 className="h-3.5 w-3.5 animate-spin" />收口中...</>) : (finalizeCountdown > 0 ? `确认收口为 ${finalizeStatus} (${finalizeCountdown}s)` : `确认收口为 ${finalizeStatus}`)}
</button>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */} {/* 删除确认弹窗 */}
{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">