Files
track/frontend/src/pages/admin/AdminProductsPage.tsx
openhands 14f707461d feat(admin): 新增「操作审计」页面 + 收敛前端角色判断
- pages/admin/AdminAuditLogPage.tsx:审计日志查询页。支持操作人/模块/动作/
  结果状态/日期区间筛选,表格按时间倒序,行内「详情」抽屉展示完整 URL、
  UA、request_id、错误信息与变更详情。
  中文标签(模块/动作)由服务端下发,前端不维护枚举映射 —— 与
  constants/task.ts 里状态映射的既有做法一致,避免两端各写一份开始漂移。
- services/auditApi.ts:审计接口客户端与类型定义。
- 路由 /admin/audit + 侧边栏「操作审计」入口。
- AdminProductsPage 里手写的角色判断改为复用 isAdminRole():这是同一份
  「管理员角色」规则的第 4 处副本,本次一并对齐(另一处 TaskFlowView 已在用)。
  constants/task.ts 的注释同步指向后端新位置 core/roles.py。

验证:tsc --noEmit 通过;vite build 通过(产出独立 chunk
AdminAuditLogPage-*.js);对接真实后端校验响应字段与 TS 接口定义逐字段一致。
2026-09-21 02:28:17 +00:00

447 lines
32 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.

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<ProductResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
// 搜索 & 筛选 & 分组
const [keyword, setKeyword] = useState("");
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 打印
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 [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 [editOrderNo, setEditOrderNo] = useState("");
const [editExternalSerial, setEditExternalSerial] = useState("");
const [editSaving, setEditSaving] = useState(false);
// 删除防呆
const [deleteTarget, setDeleteTarget] = useState<ProductResponse | null>(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<ProductResponse[]>("/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<ProductGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
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 (
<div>
{/* 标题栏 */}
<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></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 hover:bg-gray-50"><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 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 hover:bg-gray-50"><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></button>
</div>
</div>
{/* 搜索 + 分组 + 状态过滤(移植自 AdminTasksPage */}
<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={() => setExpandedGroups(new Set(productGroups.map(g => g.groupKey)))}
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50"></button>
<button onClick={() => setExpandedGroups(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-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>
</form>
<div className="mt-3 flex gap-1.5 flex-wrap">
<button onClick={() => setStatusFilters(new Set())}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${statusFilters.size === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
</button>
{STATUS_TABS.map(tab => {
const active = statusFilters.has(tab.key);
return (
<button key={tab.key} onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${active ? "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">
<QrCode className="mb-3 h-12 w-12" /><p></p>
</div>
)}
{/* 🚀 分组折叠面板 */}
{!loading && productGroups.length > 0 && (
<div className="space-y-3">
{productGroups.map(group => {
const isOpen = expandedGroups.has(group.groupKey);
return (
<div key={group.groupKey} className="overflow-hidden rounded-xl bg-white shadow-sm">
<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">
{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>
{/* 产品卡片网格 */}
{isOpen && (
<div className="border-t border-gray-100 px-5 py-4">
<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} onClick={() => 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">
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
<button onClick={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); 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 className="mt-1 flex flex-wrap items-center justify-center gap-1.5">
{(() => {
const cfg = getStatusConfig(p.macro_status || p.status);
return cfg.label ? (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
) : null;
})()}
{/* 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示 */}
{(() => {
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase);
return lb ? (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${lb.className}`}>{lb.label}</span>
) : null;
})()}
</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={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 || "—"} />
<div className="flex items-center gap-2 text-xs">
<Timer className="h-3 w-3 shrink-0 text-gray-400" />
<span className="shrink-0 text-gray-400"></span>
{(() => {
const h = p.active_duration_hours;
if (h == null) return <span className="truncate font-medium text-gray-700"></span>;
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 <span className={`rounded-md px-1.5 py-0.5 font-bold ${cls}`}>{formatDuration(h)}</span>;
})()}
</div>
<InfoRow icon={CalendarDays} label="生产总天数" value={`${p.production_days ?? formatProductionDays(p.created_at)}`} />
<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")} />
</div>
<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">
<Printer className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
<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="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"><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>
<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>
<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>
</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"><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)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
<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>
</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>
)}
{/* 收口弹窗(入库/出库,仅管理员) */}
{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 && (
<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-4 text-sm text-gray-600"> <span className="font-mono font-bold">{deleteTarget.serial_number}</span> </p>
<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 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>
</div>
)}
</div>
);
}
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" /><span className="shrink-0 text-gray-400">{label}</span><span className="truncate font-medium text-gray-700">{value}</span></div>;
}
/** 滞留时长格式化:小时 → 天/小时/分钟 */
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}`;
}