feat: 产品管理 — 当前位置显示真实姓名 + 编辑弹窗 + 删除确认

This commit is contained in:
2026-08-07 15:38:01 +08:00
parent d3d501dfeb
commit bc43b5a732
5 changed files with 254 additions and 180 deletions

View File

@ -103,6 +103,16 @@ async def update_product_endpoint(
return await product_service.update_product(db, uuid.UUID(product_id), data)
@router.delete("/{product_id}", status_code=204)
async def delete_product_endpoint(
product_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除产品及其关联任务"""
import uuid
await product_service.delete_product(db, uuid.UUID(product_id))
# ============================================================
# 宏观状态更新 — 扫码定调
# ============================================================

View File

@ -50,6 +50,7 @@ class ProductResponse(BaseModel):
material_type: str | None = None
parent_product_id: uuid.UUID | None
current_location_id: str | None = None
current_location_name: str | None = None
overall_status: str | None = None
status: str
created_at: datetime

View File

@ -278,6 +278,35 @@ async def update_overall_status(db: AsyncSession, serial_number: str, status_val
return await get_product_by_serial(db, serial_number)
def _lookup_display_names(location_ids: list[str]) -> dict[str, str]:
"""批量查询 MOM sys_user,将 username 映射为真实姓名"""
if not location_ids:
return {}
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text
db = MomSessionLocal()
try:
# 过滤掉特殊值
real_ids = [uid for uid in location_ids if uid and uid != "virtual_warehouse"]
if not real_ids:
return {}
# 用 LIKE 模糊匹配批量查出
conditions = " OR ".join([f"username LIKE '%/{uid}'" for uid in real_ids])
result = db.execute(
text(f"SELECT username, SPLIT_PART(username, '/', 1) as display_name FROM sys_user WHERE {conditions}")
)
mapping = {}
for row in result:
full_username = row[0]
display_name = row[1]
# 从 full_username 末尾提取短用户名: "张三/zhangsan01" → "zhangsan01"
short = full_username.split("/")[-1] if "/" in full_username else full_username
mapping[short] = display_name
return mapping
finally:
db.close()
async def get_all_products(
db: AsyncSession,
skip: int = 0,
@ -341,6 +370,11 @@ async def get_all_products(
result = await db.execute(stmt)
products = result.scalars().all()
# 批量查询当前位置对应的真实姓名
location_ids = [p.current_location_id for p in products if p.current_location_id]
name_map = _lookup_display_names(location_ids)
return [
ProductResponse(
id=p.id,
@ -355,9 +389,37 @@ async def get_all_products(
material_type=p.material_type,
parent_product_id=p.parent_product_id,
current_location_id=p.current_location_id,
current_location_name=(
"仓库" if p.current_location_id == "virtual_warehouse"
else name_map.get(p.current_location_id) if p.current_location_id
else None
),
overall_status=p.overall_status,
status=p.status,
created_at=p.created_at,
)
for p in products
]
async def delete_product(db: AsyncSession, product_id: uuid.UUID) -> None:
"""删除产品及其关联任务"""
product = await get_product(db, product_id)
# 删除关联任务记录
from app.models.task import TaskRecord
tasks_result = await db.execute(
select(Task).where(Task.product_id == product_id)
)
tasks = tasks_result.scalars().all()
for task in tasks:
await db.execute(
select(TaskRecord).where(TaskRecord.task_id == task.id)
)
# 级联删除已在模型中定义,直接删任务
# 删除产品(task 有外键 CASCADE?检查模型)
# 手动删关联任务确保完整
for task in tasks:
await db.delete(task)
await db.delete(product)
await db.commit()

View File

@ -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" />

View File

@ -13,6 +13,7 @@ export interface ProductResponse {
material_type: string | null;
parent_product_id: string | null;
current_location_id: string | null;
current_location_name: string | null;
overall_status: string | null;
status: string;
created_at: string;