2026-08-07 11:44:12 +08:00
|
|
|
|
import { useEffect, useState, useRef, useCallback } from "react";
|
2026-08-05 14:01:36 +08:00
|
|
|
|
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
|
2026-08-07 11:44:12 +08:00
|
|
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
2026-08-04 17:10:01 +08:00
|
|
|
|
import api from "../../services/api";
|
|
|
|
|
|
import type { ProductResponse } from "../../types/admin";
|
|
|
|
|
|
import CreateProductDialog from "./CreateProductDialog";
|
2026-08-05 14:01:36 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getLabelPreview,
|
|
|
|
|
|
executePrint,
|
|
|
|
|
|
type LabelPreviewRequest,
|
|
|
|
|
|
} from "../../services/printApi";
|
|
|
|
|
|
import { useToast } from "../../components/ui/Toast";
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
|
|
|
|
|
const QR_BASE = "/api/v1/products/qrcode";
|
|
|
|
|
|
|
|
|
|
|
|
export default function AdminProductsPage() {
|
2026-08-05 14:01:36 +08:00
|
|
|
|
const { toast } = useToast();
|
2026-08-04 17:10:01 +08:00
|
|
|
|
const [products, setProducts] = useState<ProductResponse[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
// 打印弹窗状态
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-07 11:44:12 +08:00
|
|
|
|
// 🚀 虚拟滚动:响应式列数 + 行虚拟化
|
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const [columns, setColumns] = useState(5);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = scrollRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const calc = () => {
|
|
|
|
|
|
const w = el.clientWidth;
|
|
|
|
|
|
if (w >= 1280) setColumns(6);
|
|
|
|
|
|
else if (w >= 1024) setColumns(5);
|
|
|
|
|
|
else if (w >= 768) setColumns(4);
|
|
|
|
|
|
else if (w >= 640) setColumns(3);
|
|
|
|
|
|
else setColumns(2);
|
|
|
|
|
|
};
|
|
|
|
|
|
calc();
|
|
|
|
|
|
const ro = new ResizeObserver(calc);
|
|
|
|
|
|
ro.observe(el);
|
|
|
|
|
|
return () => ro.disconnect();
|
|
|
|
|
|
}, [products.length > 0]);
|
|
|
|
|
|
|
|
|
|
|
|
const rowCount = Math.ceil(products.length / columns);
|
|
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
|
|
|
|
count: rowCount,
|
|
|
|
|
|
getScrollElement: () => scrollRef.current,
|
|
|
|
|
|
estimateSize: () => 320,
|
|
|
|
|
|
overscan: 2,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-04 17:10:01 +08:00
|
|
|
|
async function loadProducts() {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.get<ProductResponse[]>("/products/");
|
|
|
|
|
|
setProducts(data);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setError("加载产品列表失败,请检查后端服务");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadProducts();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
// ---- 打印标签 ----
|
|
|
|
|
|
|
|
|
|
|
|
async function handleOpenPrint(product: ProductResponse) {
|
|
|
|
|
|
setPrintTarget(product);
|
|
|
|
|
|
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 ?? "",
|
|
|
|
|
|
};
|
|
|
|
|
|
const url = await getLabelPreview(payload);
|
|
|
|
|
|
setPreviewUrl(url);
|
|
|
|
|
|
} 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 handlePrintQr(serialNumber: string) {
|
2026-08-04 17:10:01 +08:00
|
|
|
|
const qrUrl = `${QR_BASE}/${serialNumber}`;
|
|
|
|
|
|
const w = window.open("", "_blank", "width=400,height=500");
|
|
|
|
|
|
if (!w) return;
|
|
|
|
|
|
w.document.write(`
|
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html>
|
|
|
|
|
|
<head><title>打印标签 - ${serialNumber}</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
|
body { display: flex; flex-direction: column; align-items: center; padding: 24px; font-family: monospace; }
|
|
|
|
|
|
img { width: 280px; height: 280px; image-rendering: pixelated; }
|
|
|
|
|
|
.sn { margin-top: 12px; font-size: 22px; font-weight: bold; letter-spacing: 2px; color: #1f2937; }
|
|
|
|
|
|
.hint { margin-top: 8px; font-size: 12px; color: #9ca3af; }
|
|
|
|
|
|
@media print { body { padding: 0; } img { width: 260px; height: 260px; } }
|
|
|
|
|
|
</style></head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<img src="${qrUrl}" alt="QR-${serialNumber}" />
|
|
|
|
|
|
<p class="sn">${serialNumber}</p>
|
|
|
|
|
|
<p class="hint">扫描二维码查询生产进度</p>
|
|
|
|
|
|
<script>window.onload=()=>window.print()</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|
|
|
|
|
|
`);
|
|
|
|
|
|
w.document.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2026-08-07 11:44:12 +08:00
|
|
|
|
查看所有产品身份证并生成二维码用于打印标签
|
2026-08-04 17:10:01 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
2026-08-05 14:01:36 +08:00
|
|
|
|
<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="打印机设置"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings className="h-4 w-4" />
|
|
|
|
|
|
</a>
|
2026-08-04 17:10:01 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<p className="mt-1 text-sm">创建产品后将在此显示二维码</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && products.length > 0 && (
|
2026-08-07 11:44:12 +08:00
|
|
|
|
<div
|
|
|
|
|
|
ref={scrollRef}
|
|
|
|
|
|
className="h-[calc(100vh-220px)] overflow-auto rounded-xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
height: rowVirtualizer.getTotalSize(),
|
|
|
|
|
|
position: "relative",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
|
|
|
|
const start = virtualRow.index * columns;
|
|
|
|
|
|
const rowProducts = products.slice(start, start + columns);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={virtualRow.key}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: "absolute",
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
width: "100%",
|
|
|
|
|
|
height: virtualRow.size,
|
|
|
|
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="grid gap-4 px-1"
|
|
|
|
|
|
data-index={virtualRow.index}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 动态 inline grid 列数(Tailwind 不支持动态 columns) */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="grid gap-4"
|
|
|
|
|
|
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{rowProducts.map((p) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={p.id}
|
|
|
|
|
|
className="flex flex-col items-center rounded-xl bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 二维码 */}
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={`${QR_BASE}/${p.serial_number}`}
|
|
|
|
|
|
alt={`QR-${p.serial_number}`}
|
|
|
|
|
|
className="h-40 w-40 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>
|
|
|
|
|
|
<p className="mt-0.5 text-xs text-gray-400">
|
|
|
|
|
|
{p.order_no ?? "—"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 打印按钮 */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleOpenPrint(p)}
|
|
|
|
|
|
className="mt-3 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>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-08-04 17:10:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-05 14:01:36 +08:00
|
|
|
|
|
2026-08-04 17:10:01 +08:00
|
|
|
|
<CreateProductDialog
|
|
|
|
|
|
open={showCreate}
|
|
|
|
|
|
onClose={() => setShowCreate(false)}
|
|
|
|
|
|
onCreated={loadProducts}
|
|
|
|
|
|
/>
|
2026-08-05 14:01:36 +08:00
|
|
|
|
|
|
|
|
|
|
{/* ============================================================ */}
|
|
|
|
|
|
{/* 打印预览弹窗 */}
|
|
|
|
|
|
{/* ============================================================ */}
|
|
|
|
|
|
{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 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 预览图 */}
|
|
|
|
|
|
<div className="mb-4 flex justify-center">
|
|
|
|
|
|
{printLoading || !previewUrl ? (
|
|
|
|
|
|
<div className="flex items-center justify-center rounded-lg bg-gray-50 w-full h-48">
|
|
|
|
|
|
<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 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>
|
|
|
|
|
|
)}
|
2026-08-04 17:10:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|