diff --git a/backend/app/api/v1/endpoints/products.py b/backend/app/api/v1/endpoints/products.py index e594659..cd628c7 100644 --- a/backend/app/api/v1/endpoints/products.py +++ b/backend/app/api/v1/endpoints/products.py @@ -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)) + + # ============================================================ # 宏观状态更新 — 扫码定调 # ============================================================ diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index cff00f3..ae187b5 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -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 diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index bccf6ba..45316da 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -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() diff --git a/frontend/src/pages/admin/AdminProductsPage.tsx b/frontend/src/pages/admin/AdminProductsPage.tsx index de77229..6b675f6 100644 --- a/frontend/src/pages/admin/AdminProductsPage.tsx +++ b/frontend/src/pages/admin/AdminProductsPage.tsx @@ -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(null); const [showCreate, setShowCreate] = useState(false); - // 打印弹窗状态 + // 打印弹窗 const [printTarget, setPrintTarget] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [printLoading, setPrintLoading] = useState(false); const [printCopies, setPrintCopies] = useState(1); const [printing, setPrinting] = useState(false); + // 编辑弹窗 + const [editTarget, setEditTarget] = useState(null); + const [editOrderNo, setEditOrderNo] = useState(""); + const [editExternalSerial, setEditExternalSerial] = useState(""); + const [editSaving, setEditSaving] = useState(false); + + // 删除确认 + const [deleteTarget, setDeleteTarget] = useState(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() {

产品管理

-

- 查看所有产品身份证并生成二维码用于打印标签 -

+

查看所有产品身份证并生成二维码用于打印标签

- + - -
- {/* 错误 */} - {error && ( -
- {error} -
- )} - - {/* 加载中 */} - {loading && ( -
- -
- )} - - {/* 空状态 */} + {error &&
{error}
} + {loading &&
} {!loading && products.length === 0 && !error && (
- -

暂无产品数据

-

创建产品后将在此显示二维码

+

暂无产品数据

创建产品后将在此显示二维码

)} - {/* 🚀 响应式网格 — 稳定不拉伸 */} + {/* 产品卡片网格 */} {!loading && products.length > 0 && (
- {products.map((p) => { - const statusCfg = getStatusConfig(p.status); - return ( -
- {/* 顶部:二维码 + 产品ID */} -
- {`QR-${p.serial_number}`} -

- {p.serial_number} -

-
- - {/* 主体:关键信息键值对 */} -
- - - - - - {p.overall_status && ( -
- 宏观状态 - - {p.overall_status} - -
- )} -
- - {/* 底部:打印按钮 */} -
- -
+ {products.map((p) => ( +
+ {/* 操作图标 — hover 显示 */} +
+ +
- ); - })} + + {/* 二维码 + ID */} +
+ {`QR-${p.serial_number}`} +

{p.serial_number}

+
+ + {/* 信息区 */} +
+ + + + + +
+ + {/* 打印 */} +
+ +
+
+ ))}
)} - setShowCreate(false)} - onCreated={loadProducts} - /> + setShowCreate(false)} onCreated={loadProducts} /> {/* 打印预览弹窗 */} {printTarget && (
-
!printing && setPrintTarget(null)} - /> +
!printing && setPrintTarget(null)} />

标签打印预览

- +
-
{printLoading || !previewUrl ? ( -
- -
+
) : ( - 标签预览 + 标签预览 )}
- -

- {printTarget.serial_number} -

- +

{printTarget.serial_number}

打印份数
- + {printCopies} - + +
+
+
+ + +
+
+
+ )} + + {/* 编辑弹窗 */} + {editTarget && ( +
+
!editSaving && setEditTarget(null)} /> +
+
+

编辑产品

+ +
+ +

产品ID: {editTarget.serial_number}

+ +
+
+ + 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" /> +
+
+ + 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" />
-
- + -
+
+
+ )} + + {/* 删除确认弹窗 */} + {deleteTarget && ( +
+
!deleting && setDeleteTarget(null)} /> +
+
+
+ +
+
+

确认删除

+

此操作不可恢复

+
+
+ +

+ 确定要删除产品 {deleteTarget.serial_number} 吗? +

+

将同时删除该产品关联的所有任务和二维码。

+ +
+ +
@@ -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 (
diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index f229f40..f1a100b 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -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;