fix: 产品管理页响应式Grid + 丰富卡片信息(物料/规格/位置/状态)
This commit is contained in:
@ -1,6 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Package, Hash, Tag, MapPin, Clock } from "lucide-react";
|
||||
import api from "../../services/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import CreateProductDialog from "./CreateProductDialog";
|
||||
@ -10,6 +9,7 @@ import {
|
||||
type LabelPreviewRequest,
|
||||
} from "../../services/printApi";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
|
||||
const QR_BASE = "/api/v1/products/qrcode";
|
||||
|
||||
@ -27,35 +27,6 @@ export default function AdminProductsPage() {
|
||||
const [printCopies, setPrintCopies] = useState(1);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
|
||||
// 🚀 虚拟滚动:响应式列数 + 行虚拟化
|
||||
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,
|
||||
});
|
||||
|
||||
async function loadProducts() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -118,35 +89,6 @@ export default function AdminProductsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 打印二维码(兼容旧功能) ----
|
||||
|
||||
function handlePrintQr(serialNumber: string) {
|
||||
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>
|
||||
{/* 标题栏 */}
|
||||
@ -196,7 +138,7 @@ export default function AdminProductsPage() {
|
||||
</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" />
|
||||
@ -205,75 +147,71 @@ export default function AdminProductsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🚀 响应式网格 — 稳定不拉伸 */}
|
||||
{!loading && products.length > 0 && (
|
||||
<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 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>
|
||||
|
||||
{/* 主体:关键信息键值对 */}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -283,9 +221,7 @@ export default function AdminProductsPage() {
|
||||
onCreated={loadProducts}
|
||||
/>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* 打印预览弹窗 */}
|
||||
{/* ============================================================ */}
|
||||
{printTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
@ -294,9 +230,7 @@ export default function AdminProductsPage() {
|
||||
/>
|
||||
<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>
|
||||
<h3 className="text-base font-bold text-gray-800">标签打印预览</h3>
|
||||
<button
|
||||
onClick={() => setPrintTarget(null)}
|
||||
disabled={printing}
|
||||
@ -306,10 +240,9 @@ export default function AdminProductsPage() {
|
||||
</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">
|
||||
<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>
|
||||
) : (
|
||||
@ -325,7 +258,6 @@ export default function AdminProductsPage() {
|
||||
{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">
|
||||
@ -335,9 +267,7 @@ export default function AdminProductsPage() {
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-semibold">
|
||||
{printCopies}
|
||||
</span>
|
||||
<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"
|
||||
@ -347,7 +277,6 @@ export default function AdminProductsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setPrintTarget(null)}
|
||||
@ -371,3 +300,22 @@ export default function AdminProductsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user