- 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 接口定义逐字段一致。
447 lines
32 KiB
TypeScript
447 lines
32 KiB
TypeScript
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} 天`;
|
||
}
|