@ -1,5 +1,8 @@
import { useEffect , useState } from "react" ;
import { Printer , RefreshCw , Loader2 , QrCode , Plus , Settings , X , Package , Hash , Tag , MapPin , Clock } from "lucide-react" ;
import {
Printer , RefreshCw , Loader2 , QrCode , Plus , Settings , X ,
Package , Hash , Tag , MapPin , Clock , Pencil , Trash2 , Save , AlertTriangle ,
} from "lucide-react" ;
import api from "../../services/api" ;
import type { ProductResponse } from "../../types/admin" ;
import CreateProductDialog from "./CreateProductDialog" ;
@ -9,7 +12,6 @@ import {
type LabelPreviewRequest ,
} from "../../services/printApi" ;
import { useToast } from "../../components/ui/Toast" ;
import { getStatusConfig } from "../../constants/task" ;
const QR_BASE = "/api/v1/products/qrcode" ;
@ -20,13 +22,23 @@ export default function AdminProductsPage() {
const [ error , setError ] = useState < string | null > ( null ) ;
const [ showCreate , setShowCreate ] = useState ( false ) ;
// 打印弹窗状态
// 打印弹窗
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 [ 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 ) ;
async function loadProducts() {
setLoading ( true ) ;
setError ( null ) ;
@ -40,18 +52,14 @@ export default function AdminProductsPage() {
}
}
useEffect ( ( ) = > {
loadProducts ( ) ;
} , [ ] ) ;
// ---- 打印标签 ----
useEffect ( ( ) = > { loadProducts ( ) ; } , [ ] ) ;
// ---- 打印 ----
async function handleOpenPrint ( product : ProductResponse ) {
setPrintTarget ( product ) ;
setPreviewUrl ( null ) ;
setPrintCopies ( 1 ) ;
setPrintLoading ( true ) ;
try {
const payload : LabelPreviewRequest = {
serial_number : product.serial_number ,
@ -59,8 +67,7 @@ export default function AdminProductsPage() {
spec_model : product.spec_model ? ? "" ,
order_no : product.order_no ? ? "" ,
} ;
const url = await getLabelPreview ( payload ) ;
setPreviewUrl ( url ) ;
setPreviewUrl ( await getLabelPreview ( payload ) ) ;
} catch ( err : any ) {
toast ( err ? . response ? . data ? . detail ? ? err ? . message ? ? "生成预览失败" , "error" ) ;
setPrintTarget ( null ) ;
@ -84,9 +91,48 @@ export default function AdminProductsPage() {
setPrintTarget ( null ) ;
} catch ( err : any ) {
toast ( err ? . response ? . data ? . detail ? ? err ? . message ? ? "打印失败" , "error" ) ;
} finally {
setPrinting ( false ) ;
}
} finally { setPrinting ( false ) ; }
}
// ---- 编辑 ----
function openEdit ( product : ProductResponse ) {
setEditTarget ( product ) ;
setEditOrderNo ( product . order_no ? ? "" ) ;
setEditExternalSerial ( product . 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 : any ) {
toast ( err ? . response ? . data ? . detail ? ? "保存失败" , "error" ) ;
} finally { setEditSaving ( false ) ; }
}
// ---- 删除 ----
function confirmDelete ( product : ProductResponse ) {
setDeleteTarget ( product ) ;
}
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 ) ; }
}
return (
@ -95,203 +141,166 @@ export default function AdminProductsPage() {
< 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 >
< 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 transition-colors hover:bg-gray-50"
title = "打印机设置"
>
< 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" title = "打印机设置" >
< 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 transition-colors hover:bg-blue-700"
>
< Plus className = "h-4 w-4" / >
创 建 产 品
< 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 transition-colors hover:bg-gray-50"
>
< RefreshCw className = { ` h-4 w-4 ${ loading ? "animate-spin" : "" } ` } / >
刷 新
< 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 >
{ /* 错误 */ }
{ 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 >
) }
{ /* 空状态 */ }
{ 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 >
< QrCode className = "mb-3 h-12 w-12" / > < p > 暂 无 产 品 数 据 < / p > < p className = "mt-1 text-sm" > 创 建 产 品 后 将 在 此 显 示 二 维 码 < / p >
< / div >
) }
{ /* 🚀 响应式网格 — 稳定不拉伸 */ }
{ /* 产品卡片网格 */ }
{ ! loading && products . length > 0 && (
< 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 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 >
{ products . map ( ( p ) = > (
< div key = { p . id } className = "group relative flex flex-col rounded-xl bg-white shadow-sm transition-shadow hover:shadow-md" >
{ /* 操作图标 — hover 显示 */ }
< div className = "absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100" >
< button onClick = { ( ) = > openEdit ( p ) } className = "rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600" title = "编辑" >
< Pencil className = "h-3.5 w-3.5" / >
< / button >
< button onClick = { ( ) = > confirmDelete ( p ) } className = "rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500" title = "删除" >
< Trash2 className = "h-3.5 w-3.5" / >
< / button >
< / div >
) ;
} ) }
{ /* 二维码 + 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 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_name || p . current_location_id || "—" } / >
< InfoRow icon = { Clock } label = "创建时间" value = { new Date ( p . created_at ) . toLocaleDateString ( "zh-CN" ) } / >
< / 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 hover:bg-blue-100 hover:border-blue-300" >
< Printer className = "h-3.5 w-3.5" / > 打 印 标 签
< / button >
< / div >
< / div >
) ) }
< / div >
) }
< CreateProductDialog
open = { showCreate }
onClose = { ( ) = > setShowCreate ( false ) }
onCreated = { loadProducts }
/ >
< 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 = "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 >
< 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 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 >
< 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"
/ >
< 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 >
< 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 >
< 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 >
< 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 >
) }
{ /* 编辑弹窗 */ }
{ 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 hover:text-gray-600 disabled:opacity-50" > < 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 ) } placeholder = "请输入订单编号" className = "w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" / >
< / div >
< div >
< label className = "mb-1 block text-sm font-medium text-gray-700" > 产 品 序 列 号 < / label >
< input value = { editExternalSerial } onChange = { ( e ) = > setEditExternalSerial ( e . target . value ) } placeholder = "请输入产品序列号" className = "w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" / >
< / 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"
>
取 消
< div className = "mt-6 flex justify-end gap-2" >
< button onClick = { ( ) = > setEditTarget ( null ) } disabled = { editSaving } 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 = { 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 hover:bg-blue-700 disabled:opacity-50" >
{ editSaving && < Loader2 className = "h-3.5 w-3.5 animate-spin" / > }
< Save className = "h-3.5 w-3.5" / > 保 存
< / 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" / > }
确 认 打 印
< / 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-2 text-sm text-gray-600" >
确 定 要 删 除 产 品 < span className = "font-mono font-bold" > { deleteTarget . serial_number } < / span > 吗 ?
< / p >
< p className = "mb-6 text-xs text-red-500" > 将 同 时 删 除 该 产 品 关 联 的 所 有 任 务 和 二 维 码 。 < / p >
< div className = "flex justify-end gap-2" >
< button onClick = { ( ) = > setDeleteTarget ( null ) } disabled = { deleting } 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 = { handleDelete } disabled = { deleting } className = "flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50" >
{ deleting && < Loader2 className = "h-3.5 w-3.5 animate-spin" / > }
< Trash2 className = "h-3.5 w-3.5" / > 确 认 删 除
< / button >
< / div >
< / div >
@ -301,16 +310,7 @@ export default function AdminProductsPage() {
) ;
}
/** 信息行:图标 + 标签 + 值 */
function InfoRow ( {
icon : Icon ,
label ,
value ,
} : {
icon : React.ComponentType < { className? : string } > ;
label : string ;
value : string ;
} ) {
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" / >