diff --git a/admin-frontend/src/api.ts b/admin-frontend/src/api.ts index a29fff3..fe2f69c 100644 --- a/admin-frontend/src/api.ts +++ b/admin-frontend/src/api.ts @@ -86,6 +86,20 @@ export const api = { request<{ status: string }>(`/products/${id}/msrp/${msrpId}`, { method: "DELETE", }), + addBarcode: (id: string, body: unknown) => + request(`/products/${id}/barcodes`, { + method: "POST", + body: JSON.stringify(body), + }), + deleteBarcode: (id: string, barcodeId: string) => + request<{ status: string }>(`/products/${id}/barcodes/${barcodeId}`, { + method: "DELETE", + }), + setPrimaryBarcode: (id: string, barcodeId: string) => + request( + `/products/${id}/barcodes/${barcodeId}/primary`, + { method: "POST" }, + ), listBrands: () => request<{ items: import("./types").Brand[] }>("/brands"), listCategories: () => diff --git a/admin-frontend/src/components/ProductDetail.tsx b/admin-frontend/src/components/ProductDetail.tsx index f18a3b2..3066803 100644 --- a/admin-frontend/src/components/ProductDetail.tsx +++ b/admin-frontend/src/components/ProductDetail.tsx @@ -14,8 +14,16 @@ import { Trash2, AlertCircle, History, + Star, } from "lucide-react"; +const GTIN_TYPES = ["EAN13", "EAN8", "UPC", "ITF14", "GTIN14"]; +const PACK_LEVELS: { value: string; label: string }[] = [ + { value: "each", label: "消费单元" }, + { value: "case", label: "箱" }, + { value: "pallet", label: "托盘" }, +]; + const NUTRIMENT_KEYS: { key: string; label: string }[] = [ { key: "energy_kcal", label: "能量 (kcal)" }, { key: "energy_kj", label: "能量 (kJ)" }, @@ -39,6 +47,9 @@ const ACTION_LABEL: Record = { delete_image: "删除图片", add_msrp: "新增建议零售价", delete_msrp: "删除建议零售价", + add_barcode: "新增条码", + delete_barcode: "删除条码", + set_primary_barcode: "设为主条码", }; function Card({ @@ -400,6 +411,7 @@ export default function ProductDetail({ + void; + onError: (m: string) => void; +}) { + const [gtin, setGtin] = useState(""); + const [gtinType, setGtinType] = useState("EAN13"); + const [packLevel, setPackLevel] = useState("each"); + const [region, setRegion] = useState(""); + const [busy, setBusy] = useState(false); + + async function add() { + if (!gtin.trim()) return; + setBusy(true); + try { + await api.addBarcode(product.id, { + gtin: gtin.trim(), + gtin_type: gtinType, + pack_level: packLevel, + region: region.trim() || null, + is_primary: false, + }); + setGtin(""); + setRegion(""); + onChange(); + } catch (e) { + onError(e instanceof Error ? e.message : "添加失败"); + } finally { + setBusy(false); + } + } + async function remove(barcodeId: string) { + try { + await api.deleteBarcode(product.id, barcodeId); + onChange(); + } catch (e) { + onError(e instanceof Error ? e.message : "删除失败"); + } + } + async function makePrimary(barcodeId: string) { + try { + await api.setPrimaryBarcode(product.id, barcodeId); + onChange(); + } catch (e) { + onError(e instanceof Error ? e.message : "设置失败"); + } + } + + return ( + +
+ {product.barcodes.length === 0 && ( + 暂无条码 + )} + {product.barcodes.map((b) => ( +
+ + + {b.gtin} + + + {b.gtin_type} + + + {PACK_LEVELS.find((p) => p.value === b.pack_level)?.label || + b.pack_level} + + {b.region || ""} + +
+ ))} +
+
+ + setGtin(e.target.value)} + placeholder="8/12/13/14 位" + /> + + + + + + + + + setRegion(e.target.value.toUpperCase())} + /> + + +
+
+ ); +} + function ImagesCard({ product, onChange, diff --git a/admin-frontend/src/types.ts b/admin-frontend/src/types.ts index 8279181..a07aeae 100644 --- a/admin-frontend/src/types.ts +++ b/admin-frontend/src/types.ts @@ -10,6 +10,15 @@ export interface ProductRow { updated_at: string; } +export interface Barcode { + id: string; + gtin: string; + gtin_type: string; + pack_level: string; + region: string | null; + is_primary: boolean; +} + export interface ProductImage { id: string; url: string; @@ -47,6 +56,7 @@ export interface ProductDetail { nutrition_basis: string | null; serving_size: string | null; nutri_score: string | null; + barcodes: Barcode[]; images: ProductImage[]; msrp: MSRP[]; missing: string[]; diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index 94da7a4..3eb7e3f 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -15,6 +15,7 @@ import ( "github.com/baicai2026-baicai/goods/api/internal/adminstore" "github.com/baicai2026-baicai/goods/api/internal/auth" + "github.com/baicai2026-baicai/goods/api/internal/gtin" ) // Handler holds the admin dependencies. @@ -62,6 +63,9 @@ func (h *Handler) Router() http.Handler { r.Delete("/api/products/{id}/images/{imageID}", h.DeleteImage) r.Post("/api/products/{id}/msrp", h.AddMSRP) r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP) + r.Post("/api/products/{id}/barcodes", h.AddBarcode) + r.Delete("/api/products/{id}/barcodes/{barcodeID}", h.DeleteBarcode) + r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode) r.Get("/api/brands", h.ListBrands) r.Get("/api/categories", h.ListCategories) @@ -231,6 +235,68 @@ func (h *Handler) DeleteMSRP(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } +// ---------- barcodes ---------- + +// AddBarcode validates and attaches a barcode to a product. A code already +// owned by another product yields 409 with the conflicting product so the +// operator can de-duplicate; an invalid GTIN yields 400. +func (h *Handler) AddBarcode(w http.ResponseWriter, r *http.Request) { + var in adminstore.BarcodeInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + b, err := h.store.AddBarcode(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in) + if h.handleBarcodeErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, b) +} + +// DeleteBarcode removes a barcode; a primary one is replaced automatically. +func (h *Handler) DeleteBarcode(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context())) + if h.handleErr(w, err) { + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin. +func (h *Handler) SetPrimaryBarcode(w http.ResponseWriter, r *http.Request) { + b, err := h.store.SetPrimaryBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context())) + if h.handleErr(w, err) { + return + } + writeJSON(w, http.StatusOK, b) +} + +// handleBarcodeErr maps barcode-specific errors (GTIN validation, ownership +// conflict) to client-facing statuses, falling back to handleErr otherwise. +func (h *Handler) handleBarcodeErr(w http.ResponseWriter, err error) bool { + if err == nil { + return false + } + var conflict *adminstore.ConflictError + if errors.As(err, &conflict) { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": map[string]string{"code": "barcode_conflict", "message": err.Error()}, + "conflict": map[string]string{ + "gtin": conflict.GTIN, + "product_id": conflict.ProductID, + "product_name": conflict.ProductName, + }, + }) + return true + } + if errors.Is(err, gtin.ErrEmpty) || errors.Is(err, gtin.ErrFormat) || + errors.Is(err, gtin.ErrCheck) || errors.Is(err, gtin.ErrRestricted) { + writeError(w, http.StatusBadRequest, "invalid_gtin", err.Error()) + return true + } + return h.handleErr(w, err) +} + // ListBrands returns brand options. func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) { items, err := h.store.ListBrands(r.Context()) diff --git a/public-frontend/src/components/ProductView.tsx b/public-frontend/src/components/ProductView.tsx index 06e4f78..bfa4987 100644 --- a/public-frontend/src/components/ProductView.tsx +++ b/public-frontend/src/components/ProductView.tsx @@ -60,6 +60,29 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () =>
+ {(() => { + const others = (p.barcodes || []).filter( + (b) => !b.is_primary && b.gtin !== p.gtin, + ); + return others.length ? ( + + {others.map((b) => ( + + {b.gtin} + + ))} +
+ } + /> + ) : null; + })()}