From 4a693c8fe9eea9bff0f8492a2c79b664d87d023d Mon Sep 17 00:00:00 2001 From: sulaimaannaasif6866 Date: Sun, 21 Jun 2026 00:43:49 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(admin):=20=E5=88=86=E7=B1=BB=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=88=E5=88=86=E7=B1=BB=E6=A0=91=E5=A2=9E=E5=88=A0?= =?UTF-8?q?=E6=94=B9=E7=A7=BB=20+=20=E5=90=8E=E5=8F=B0=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin-frontend/src/App.tsx | 20 +- admin-frontend/src/api.ts | 12 + .../src/components/CategoriesPage.tsx | 311 ++++++++++++++++++ admin-frontend/src/types.ts | 11 + api/internal/adminhandler/handler.go | 71 ++++ api/internal/adminstore/category.go | 297 +++++++++++++++++ api/internal/adminstore/category_test.go | 130 ++++++++ api/internal/adminstore/write.go | 29 +- 8 files changed, 869 insertions(+), 12 deletions(-) create mode 100644 admin-frontend/src/components/CategoriesPage.tsx create mode 100644 api/internal/adminstore/category.go create mode 100644 api/internal/adminstore/category_test.go diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index a418e8c..3aeae20 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -5,9 +5,10 @@ import ProductList from "./components/ProductList"; import ProductDetail from "./components/ProductDetail"; import SubmissionsPage from "./components/SubmissionsPage"; import ApiKeysPage from "./components/ApiKeysPage"; -import { Inbox, KeyRound, LogOut, Package } from "lucide-react"; +import CategoriesPage from "./components/CategoriesPage"; +import { FolderTree, Inbox, KeyRound, LogOut, Package } from "lucide-react"; -type Tab = "products" | "submissions" | "keys"; +type Tab = "products" | "submissions" | "categories" | "keys"; type View = { name: "list" } | { name: "detail"; id: string }; export default function App() { @@ -100,6 +101,19 @@ export default function App() { )} + + + + {error && ( +
{error}
+ )} + + {form && ( + setForm(null)} + onSaved={() => { + setForm(null); + load(); + }} + onError={setError} + /> + )} + +
+ + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((c) => ( + + + + + + + + )) + )} + +
名称路径GPC商品数
+ 暂无分类 +
+ + {c.level > 0 && } + {c.name_zh} + {c.name_en && {c.name_en}} + + + {c.path} + {c.gpc_brick_code || "—"}{c.product_count} + + + +
+
+ + ); +} + +function CategoryForm({ + form, + categories, + onClose, + onSaved, + onError, +}: { + form: FormState; + categories: Category[]; + onClose: () => void; + onSaved: () => void; + onError: (msg: string) => void; +}) { + const [state, setState] = useState(form); + const [busy, setBusy] = useState(false); + + // When editing, the node itself and its descendants are not valid parents. + const parentOptions = useMemo(() => { + if (state.mode === "create") return categories; + const self = categories.find((c) => c.id === state.id); + if (!self) return categories; + return categories.filter( + (c) => c.id !== self.id && !c.path.startsWith(self.path + "."), + ); + }, [categories, state.mode, state.id]); + + function set(key: K, value: FormState[K]) { + setState((s) => ({ ...s, [key]: value })); + } + + async function submit() { + if (!state.name_zh.trim()) { + onError("分类名称不能为空"); + return; + } + setBusy(true); + onError(""); + const body: CategoryInput = { + name_zh: state.name_zh.trim(), + name_en: state.name_en.trim() || null, + parent_id: state.parent_id || null, + gpc_brick_code: state.gpc_brick_code.trim() || null, + }; + if (state.mode === "create") body.slug = state.slug.trim() || null; + try { + if (state.mode === "create") { + await api.createCategory(body); + } else if (state.id) { + await api.updateCategory(state.id, body); + } + onSaved(); + } catch (e) { + onError(e instanceof ApiError ? e.message : "保存失败"); + } finally { + setBusy(false); + } + } + + return ( +
+

+ {state.mode === "create" ? "新建分类" : "编辑分类"} +

+
+ + + + {state.mode === "create" ? ( + + ) : ( + + )} + +
+
+ + +
+
+ ); +} diff --git a/admin-frontend/src/types.ts b/admin-frontend/src/types.ts index 558857a..6ffa6bd 100644 --- a/admin-frontend/src/types.ts +++ b/admin-frontend/src/types.ts @@ -74,6 +74,17 @@ export interface Category { name_en: string | null; path: string; level: number; + parent_id: string | null; + gpc_brick_code: string | null; + product_count: number; +} + +export interface CategoryInput { + name_zh: string; + name_en?: string | null; + slug?: string | null; + parent_id?: string | null; + gpc_brick_code?: string | null; } export interface AuditEntry { diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index 29b6846..547eba6 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -77,6 +77,9 @@ func (h *Handler) Router() http.Handler { r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode) r.Get("/api/brands", h.ListBrands) r.Get("/api/categories", h.ListCategories) + r.Post("/api/categories", h.CreateCategory) + r.Put("/api/categories/{id}", h.UpdateCategory) + r.Delete("/api/categories/{id}", h.DeleteCategory) r.Get("/api/submissions", h.ListSubmissions) r.Get("/api/submissions/{id}", h.GetSubmission) @@ -328,6 +331,74 @@ func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +// CreateCategory adds a category node. +func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) { + var in adminstore.CategoryInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.NameZH) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "分类名称不能为空") + return + } + c, err := h.store.CreateCategory(r.Context(), auth.UserFrom(r.Context()), in) + if h.handleCategoryErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, c) +} + +// UpdateCategory renames and/or moves a category node. +func (h *Handler) UpdateCategory(w http.ResponseWriter, r *http.Request) { + var in adminstore.CategoryInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.NameZH) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "分类名称不能为空") + return + } + c, err := h.store.UpdateCategory(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in) + if h.handleCategoryErr(w, err) { + return + } + writeJSON(w, http.StatusOK, c) +} + +// DeleteCategory removes a leaf category that no product uses. +func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteCategory(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context())) + if h.handleCategoryErr(w, err) { + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// handleCategoryErr maps category-specific errors to client statuses, falling +// back to handleErr otherwise. +func (h *Handler) handleCategoryErr(w http.ResponseWriter, err error) bool { + if err == nil { + return false + } + switch { + case errors.Is(err, adminstore.ErrDuplicatePath): + writeError(w, http.StatusConflict, "duplicate_path", "该分类路径已存在,请换一个英文标识(slug)") + return true + case errors.Is(err, adminstore.ErrCategoryHasChildren): + writeError(w, http.StatusConflict, "has_children", "该分类存在子分类,请先删除或移动其子分类") + return true + case errors.Is(err, adminstore.ErrCategoryInUse): + writeError(w, http.StatusConflict, "in_use", "仍有商品归属于该分类,请先改归其它分类") + return true + case errors.Is(err, adminstore.ErrInvalidParent): + writeError(w, http.StatusBadRequest, "invalid_parent", "上级分类无效(不存在或不能移动到自身/子级下)") + return true + } + return h.handleErr(w, err) +} + // ---------- submissions ---------- // CreateSubmission accepts an anonymous public contribution into the queue. diff --git a/api/internal/adminstore/category.go b/api/internal/adminstore/category.go new file mode 100644 index 0000000..c529f94 --- /dev/null +++ b/api/internal/adminstore/category.go @@ -0,0 +1,297 @@ +package adminstore + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// randomHex returns n random lowercase hex characters for fallback ltree slugs. +func randomHex(n int) string { + b := make([]byte, (n+1)/2) + if _, err := rand.Read(b); err != nil { + return "x" + } + return hex.EncodeToString(b)[:n] +} + +// Category-management errors, mapped to client statuses by the handler. +var ( + // ErrDuplicatePath is returned when a category path already exists. + ErrDuplicatePath = errors.New("duplicate category path") + // ErrCategoryHasChildren blocks deleting a node that still has children. + ErrCategoryHasChildren = errors.New("category has children") + // ErrCategoryInUse blocks deleting a node still referenced by products. + ErrCategoryInUse = errors.New("category in use") + // ErrInvalidParent is returned for a missing parent or an illegal move + // (onto itself or one of its own descendants). + ErrInvalidParent = errors.New("invalid parent category") +) + +// CategoryInput is the payload accepted when creating or editing a category. +// Slug is the ltree label (ASCII); when empty it is derived from NameEN, then +// from a random suffix, since ltree labels cannot contain CJK or spaces. +type CategoryInput struct { + NameZH string `json:"name_zh"` + NameEN *string `json:"name_en"` + Slug *string `json:"slug"` + ParentID *string `json:"parent_id"` + GPCBrickCode *string `json:"gpc_brick_code"` +} + +var slugInvalid = regexp.MustCompile(`[^a-z0-9_]+`) + +// slugify converts a string into a valid ltree label ([a-z0-9_]). +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = slugInvalid.ReplaceAllString(s, "_") + s = strings.Trim(s, "_") + for strings.Contains(s, "__") { + s = strings.ReplaceAll(s, "__", "_") + } + return s +} + +// resolveSlug picks an ltree label from the explicit slug, then NameEN, then a +// random fallback so a Chinese-only category still gets a valid path label. +func resolveSlug(in CategoryInput) string { + if in.Slug != nil { + if s := slugify(*in.Slug); s != "" { + return s + } + } + if in.NameEN != nil { + if s := slugify(*in.NameEN); s != "" { + return s + } + } + return "cat_" + randomHex(6) +} + +func trimPtr(p *string) *string { + if p == nil { + return nil + } + t := strings.TrimSpace(*p) + if t == "" { + return nil + } + return &t +} + +// CreateCategory inserts a new category node. With no parent it becomes a root +// (level 0); otherwise its path is parentPath.slug and level is parentLevel+1. +func (s *Store) CreateCategory(ctx context.Context, actor string, in CategoryInput) (*Category, error) { + name := strings.TrimSpace(in.NameZH) + if name == "" { + return nil, errors.New("name_zh required") + } + + parentPath := "" + parentLevel := -1 + var parentID *string + if pid := trimPtr(in.ParentID); pid != nil { + var path string + var level int + err := s.pool.QueryRow(ctx, "SELECT path::text, level FROM category WHERE id = $1", *pid).Scan(&path, &level) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidParent + } + if err != nil { + return nil, err + } + parentPath, parentLevel, parentID = path, level, pid + } + + slug := resolveSlug(in) + path := slug + if parentPath != "" { + path = parentPath + "." + slug + } + level := parentLevel + 1 + + var c Category + err := s.pool.QueryRow(ctx, ` +INSERT INTO category (name_zh, name_en, parent_id, path, gpc_brick_code, level) +VALUES ($1, $2, $3, $4::ltree, $5, $6) +RETURNING id, name_zh, name_en, path::text, level, parent_id::text, gpc_brick_code, 0`, + name, trimPtr(in.NameEN), parentID, path, trimPtr(in.GPCBrickCode), level). + Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ProductCount) + if isUniqueViolation(err) { + return nil, ErrDuplicatePath + } + if err != nil { + return nil, err + } + + _ = s.writeAudit(ctx, actor, "create", "category", &c.ID, []string{"name_zh", "path"}, nil, c) + return &c, nil +} + +// UpdateCategory renames a node and/or moves it under a new parent. Moving +// rewrites the path of the node and every descendant via ltree, keeping level +// in sync. Moving a node onto itself or a descendant is rejected. +func (s *Store) UpdateCategory(ctx context.Context, id, actor string, in CategoryInput) (*Category, error) { + name := strings.TrimSpace(in.NameZH) + if name == "" { + return nil, errors.New("name_zh required") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var oldPath string + var oldLevel int + var oldParent *string + err = tx.QueryRow(ctx, "SELECT path::text, level, parent_id::text FROM category WHERE id = $1", id). + Scan(&oldPath, &oldLevel, &oldParent) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, + "UPDATE category SET name_zh = $1, name_en = $2, gpc_brick_code = $3 WHERE id = $4", + name, trimPtr(in.NameEN), trimPtr(in.GPCBrickCode), id); err != nil { + return nil, err + } + + newParent := trimPtr(in.ParentID) + if !strEq(newParent, oldParent) { + if err := s.moveCategoryTx(ctx, tx, id, oldPath, newParent); err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + out, err := s.getCategory(ctx, id) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "update", "category", &id, []string{"name_zh", "name_en", "gpc_brick_code", "parent_id"}, nil, out) + return out, nil +} + +// moveCategoryTx re-parents a subtree. The node's slug (last path label) is +// preserved; only its ancestor prefix and level change. +func (s *Store) moveCategoryTx(ctx context.Context, tx pgx.Tx, id, oldPath string, newParent *string) error { + slug := oldPath + if i := strings.LastIndex(oldPath, "."); i >= 0 { + slug = oldPath[i+1:] + } + + newBase := slug + if newParent != nil { + var parentPath string + err := tx.QueryRow(ctx, "SELECT path::text FROM category WHERE id = $1", *newParent).Scan(&parentPath) + if errors.Is(err, pgx.ErrNoRows) { + return ErrInvalidParent + } + if err != nil { + return err + } + // Disallow moving a node under itself or one of its descendants. + if parentPath == oldPath || strings.HasPrefix(parentPath, oldPath+".") { + return ErrInvalidParent + } + newBase = parentPath + "." + slug + } + + // Rewrite the node and all descendants in one statement; level tracks depth. + _, err := tx.Exec(ctx, ` +UPDATE category +SET path = ($1::ltree || subpath(path, nlevel($2::ltree) - 1)), + level = nlevel($1::ltree) + (nlevel(path) - nlevel($2::ltree)) - 1 +WHERE path = $2::ltree OR path <@ $2::ltree`, newBase, oldPath) + if isUniqueViolation(err) { + return ErrDuplicatePath + } + if err != nil { + return err + } + + _, err = tx.Exec(ctx, "UPDATE category SET parent_id = $1 WHERE id = $2", newParent, id) + return err +} + +// DeleteCategory removes a leaf node not referenced by any product. Nodes with +// children or in-use nodes are rejected with a specific error. +func (s *Store) DeleteCategory(ctx context.Context, id, actor string) error { + before, err := s.getCategory(ctx, id) + if err != nil { + return err + } + + var children int + if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM category WHERE parent_id = $1", id).Scan(&children); err != nil { + return err + } + if children > 0 { + return ErrCategoryHasChildren + } + + var products int + if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM product WHERE category_id = $1", id).Scan(&products); err != nil { + return err + } + if products > 0 { + return fmt.Errorf("%w: %d products", ErrCategoryInUse, products) + } + + ct, err := s.pool.Exec(ctx, "DELETE FROM category WHERE id = $1", id) + if err != nil { + // A concurrent product assignment can still trip the FK. + if isForeignKeyViolation(err) { + return ErrCategoryInUse + } + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + + _ = s.writeAudit(ctx, actor, "delete", "category", &id, []string{"path"}, before, nil) + return nil +} + +func (s *Store) getCategory(ctx context.Context, id string) (*Category, error) { + var c Category + err := s.pool.QueryRow(ctx, ` +SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text, + c.gpc_brick_code, + (SELECT count(*) FROM product p WHERE p.category_id = c.id) +FROM category c WHERE c.id = $1`, id). + Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ProductCount) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &c, nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} diff --git a/api/internal/adminstore/category_test.go b/api/internal/adminstore/category_test.go new file mode 100644 index 0000000..54aa979 --- /dev/null +++ b/api/internal/adminstore/category_test.go @@ -0,0 +1,130 @@ +package adminstore + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// newTestStore connects to the test database, skipping when it is unreachable +// or migrations have not been applied. +func newTestStore(t *testing.T) *Store { + t.Helper() + dsn := os.Getenv("OPENGOODS_DATABASE_URL") + if dsn == "" { + dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable" + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("no database: %v", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skipf("database not reachable: %v", err) + } + var hasTable bool + if err := pool.QueryRow(ctx, "SELECT to_regclass('public.category') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable { + pool.Close() + t.Skip("migrations not applied (category missing)") + } + t.Cleanup(pool.Close) + return New(pool) +} + +func ptr(s string) *string { return &s } + +func TestCategoryLifecycle(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + root, err := s.CreateCategory(ctx, "tester", CategoryInput{ + NameZH: "测试根", Slug: ptr("test_root_" + randomHex(6)), + }) + if err != nil { + t.Fatalf("create root: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root.Path) }) + + if root.Level != 0 || root.ParentID != nil { + t.Fatalf("root level/parent wrong: level=%d parent=%v", root.Level, root.ParentID) + } + + child, err := s.CreateCategory(ctx, "tester", CategoryInput{ + NameZH: "测试子", NameEN: ptr("Test Child"), ParentID: &root.ID, + }) + if err != nil { + t.Fatalf("create child: %v", err) + } + if child.Level != 1 || child.ParentID == nil || *child.ParentID != root.ID { + t.Fatalf("child hierarchy wrong: %+v", child) + } + + // Deleting a node with children must fail. + if err := s.DeleteCategory(ctx, root.ID, "tester"); !errors.Is(err, ErrCategoryHasChildren) { + t.Fatalf("expected ErrCategoryHasChildren, got %v", err) + } + + // Rename child. + renamed, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名"}) + if err != nil { + t.Fatalf("rename: %v", err) + } + if renamed.NameZH != "测试子-改名" { + t.Fatalf("rename not applied: %q", renamed.NameZH) + } + + // Move child to a second root, descendants' path/level should follow. + root2, err := s.CreateCategory(ctx, "tester", CategoryInput{ + NameZH: "测试根2", Slug: ptr("test_root2_" + randomHex(6)), + }) + if err != nil { + t.Fatalf("create root2: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root2.Path) }) + + moved, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名", ParentID: &root2.ID}) + if err != nil { + t.Fatalf("move: %v", err) + } + if moved.ParentID == nil || *moved.ParentID != root2.ID { + t.Fatalf("move parent wrong: %+v", moved) + } + if moved.Level != 1 { + t.Fatalf("moved level wrong: %d", moved.Level) + } + + // Moving a node under itself must be rejected. + if _, err := s.UpdateCategory(ctx, root2.ID, "tester", CategoryInput{NameZH: "测试根2", ParentID: &child.ID}); !errors.Is(err, ErrInvalidParent) { + t.Fatalf("expected ErrInvalidParent for self-move, got %v", err) + } + + // Duplicate path on create must be rejected. + if _, err := s.CreateCategory(ctx, "tester", CategoryInput{NameZH: "dup", Slug: ptr(root.Path)}); !errors.Is(err, ErrDuplicatePath) { + t.Fatalf("expected ErrDuplicatePath, got %v", err) + } + + // Now the leaf can be deleted. + if err := s.DeleteCategory(ctx, child.ID, "tester"); err != nil { + t.Fatalf("delete leaf: %v", err) + } +} + +func TestSlugify(t *testing.T) { + cases := map[string]string{ + "Cooking Oil": "cooking_oil", + " Hello--Wld": "hello_wld", + "食品": "", + "a__b": "a_b", + } + for in, want := range cases { + if got := slugify(in); got != want { + t.Errorf("slugify(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/api/internal/adminstore/write.go b/api/internal/adminstore/write.go index 466582d..238cdef 100644 --- a/api/internal/adminstore/write.go +++ b/api/internal/adminstore/write.go @@ -354,19 +354,27 @@ func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) { return out, rows.Err() } -// Category is a category option for the edit form. +// Category is a category option for the edit form and the management view. type Category struct { - ID string `json:"id"` - NameZH string `json:"name_zh"` - NameEN *string `json:"name_en"` - Path string `json:"path"` - Level int `json:"level"` + ID string `json:"id"` + NameZH string `json:"name_zh"` + NameEN *string `json:"name_en"` + Path string `json:"path"` + Level int `json:"level"` + ParentID *string `json:"parent_id"` + GPCBrickCode *string `json:"gpc_brick_code"` + ProductCount int `json:"product_count"` } -// ListCategories returns the full category tree. +// ListCategories returns the full category tree (path order) with the number of +// products directly assigned to each node. func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { - rows, err := s.pool.Query(ctx, - "SELECT id, name_zh, name_en, path::text, level FROM category ORDER BY path") + rows, err := s.pool.Query(ctx, ` +SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text, + c.gpc_brick_code, + (SELECT count(*) FROM product p WHERE p.category_id = c.id) AS product_count +FROM category c +ORDER BY c.path`) if err != nil { return nil, err } @@ -374,7 +382,8 @@ func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { out := []Category{} for rows.Next() { var c Category - if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level); err != nil { + if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, + &c.ParentID, &c.GPCBrickCode, &c.ProductCount); err != nil { return nil, err } out = append(out, c) -- 2.52.0 From a36700076e291f5e04c5cbf9f406b7904cd59fc2 Mon Sep 17 00:00:00 2001 From: sulaimaannaasif6866 Date: Sun, 21 Jun 2026 01:31:07 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(admin):=20=E5=93=81=E7=89=8C=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20+=20=E6=96=B0=E5=BB=BA=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 品牌管理:列出品牌及引用商品数,支持改名、合并重复品牌(把源品牌的商品并入目标后删除源)、删除未被引用的品牌。新建商品:商品列表新增「新建商品」入口,填写名称/条码/品牌/品类后创建并进入详情页继续补全。 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin-frontend/src/App.tsx | 17 +- admin-frontend/src/api.ts | 22 ++ admin-frontend/src/components/BrandsPage.tsx | 305 ++++++++++++++++++ admin-frontend/src/components/ProductList.tsx | 151 ++++++++- admin-frontend/src/types.ts | 1 + api/internal/adminhandler/handler.go | 118 +++++++ api/internal/adminstore/brand.go | 154 +++++++++ api/internal/adminstore/brand_test.go | 78 +++++ api/internal/adminstore/write.go | 91 +++++- 9 files changed, 926 insertions(+), 11 deletions(-) create mode 100644 admin-frontend/src/components/BrandsPage.tsx create mode 100644 api/internal/adminstore/brand.go create mode 100644 api/internal/adminstore/brand_test.go diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 3aeae20..80db3cb 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -6,9 +6,10 @@ import ProductDetail from "./components/ProductDetail"; import SubmissionsPage from "./components/SubmissionsPage"; import ApiKeysPage from "./components/ApiKeysPage"; import CategoriesPage from "./components/CategoriesPage"; -import { FolderTree, Inbox, KeyRound, LogOut, Package } from "lucide-react"; +import BrandsPage from "./components/BrandsPage"; +import { FolderTree, Inbox, KeyRound, LogOut, Package, Tag } from "lucide-react"; -type Tab = "products" | "submissions" | "categories" | "keys"; +type Tab = "products" | "submissions" | "categories" | "brands" | "keys"; type View = { name: "list" } | { name: "detail"; id: string }; export default function App() { @@ -114,6 +115,16 @@ export default function App() { > 分类管理 + + + + {error && ( +
{error}
+ )} + +
+ + setQuery(e.target.value)} + placeholder="筛选品牌名" + className="w-full rounded border border-gray-300 py-2 pl-8 pr-3 text-sm focus:border-emerald-500 focus:outline-none" + /> +
+ + {creating && ( +
+

新建品牌

+
+ + + +
+
+ )} + + {merge && ( +
+

+ 合并品牌 +

+

+ 把「{merge.source.name}」({merge.source.product_count} 个商品)合并到下面选择的品牌,源品牌将被删除。 +

+
+ + + +
+
+ )} + +
+ + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((b) => ( + + + + + + )) + )} + +
品牌商品数
+ {rows.length === 0 ? "暂无品牌" : "无匹配品牌"} +
+ {edit && edit.id === b.id ? ( + setEdit({ ...edit, name: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") saveEdit(); + if (e.key === "Escape") setEdit(null); + }} + /> + ) : ( + {b.name} + )} + {b.product_count} + {edit && edit.id === b.id ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+ + ); +} diff --git a/admin-frontend/src/components/ProductList.tsx b/admin-frontend/src/components/ProductList.tsx index 409a30e..bfe43b0 100644 --- a/admin-frontend/src/components/ProductList.tsx +++ b/admin-frontend/src/components/ProductList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { api } from "../api"; -import { FIELD_LABELS, ProductRow } from "../types"; -import { Search, AlertCircle } from "lucide-react"; +import { api, ApiError } from "../api"; +import { Brand, Category, FIELD_LABELS, ProductRow } from "../types"; +import { Search, AlertCircle, Plus } from "lucide-react"; const STATUS_LABEL: Record = { active: "在用", @@ -37,6 +37,7 @@ export default function ProductList({ const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [creating, setCreating] = useState(false); useEffect(() => { setLoading(true); @@ -79,9 +80,26 @@ export default function ProductList({ + + {creating && ( + setCreating(false)} + onCreated={(id) => { + setCreating(false); + onOpen(id); + }} + /> + )} + {error && (
{error} @@ -176,3 +194,130 @@ export default function ProductList({
); } + +function CreateProductModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (id: string) => void; +}) { + const [name, setName] = useState(""); + const [gtin, setGtin] = useState(""); + const [brand, setBrand] = useState(""); + const [categoryId, setCategoryId] = useState(""); + const [brands, setBrands] = useState([]); + const [categories, setCategories] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + api.listBrands().then((r) => setBrands(r.items)).catch(() => {}); + api.listCategories().then((r) => setCategories(r.items)).catch(() => {}); + }, []); + + async function submit() { + if (!name.trim()) { + setError("名称不能为空"); + return; + } + setBusy(true); + setError(""); + try { + const created = await api.createProduct({ + name: name.trim(), + gtin: gtin.trim() || null, + brand_name: brand.trim() || null, + category_id: categoryId || null, + status: "active", + }); + onCreated(created.id); + } catch (e) { + setError(e instanceof ApiError ? e.message : "新建失败"); + } finally { + setBusy(false); + } + } + + return ( +
+
+

新建商品

+ {error && ( +
+ {error} +
+ )} +
+ + + + +
+

+ 创建后将进入详情页,可继续补全营养、图片、多条码等信息。 +

+
+ + +
+
+
+ ); +} diff --git a/admin-frontend/src/types.ts b/admin-frontend/src/types.ts index 6ffa6bd..c74ef1a 100644 --- a/admin-frontend/src/types.ts +++ b/admin-frontend/src/types.ts @@ -66,6 +66,7 @@ export interface ProductDetail { export interface Brand { id: string; name: string; + product_count: number; } export interface Category { diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index 547eba6..df091a0 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -65,6 +65,7 @@ func (h *Handler) Router() http.Handler { r.Use(h.authn.Middleware) r.Get("/api/me", h.Me) r.Get("/api/products", h.ListProducts) + r.Post("/api/products", h.CreateProduct) r.Get("/api/products/{id}", h.GetProduct) r.Put("/api/products/{id}", h.UpdateProduct) r.Get("/api/products/{id}/audit", h.ListAudit) @@ -76,6 +77,10 @@ func (h *Handler) Router() http.Handler { 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.Post("/api/brands", h.CreateBrand) + r.Put("/api/brands/{id}", h.UpdateBrand) + r.Post("/api/brands/{id}/merge", h.MergeBrands) + r.Delete("/api/brands/{id}", h.DeleteBrand) r.Get("/api/categories", h.ListCategories) r.Post("/api/categories", h.CreateCategory) r.Put("/api/categories/{id}", h.UpdateCategory) @@ -175,6 +180,29 @@ func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, d) } +// CreateProduct adds a new product with core fields; the rest is filled in via +// the detail editor. +func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) { + var in adminstore.ProductInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "名称不能为空") + return + } + d, err := h.store.CreateProduct(r.Context(), auth.UserFrom(r.Context()), in) + if errors.Is(err, adminstore.ErrDuplicateGTIN) { + writeError(w, http.StatusConflict, "duplicate_gtin", "该条码(GTIN)已被其它商品使用") + return + } + if h.handleErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, d) +} + // UpdateProduct applies an edit. func (h *Handler) UpdateProduct(w http.ResponseWriter, r *http.Request) { var in adminstore.ProductInput @@ -399,6 +427,96 @@ func (h *Handler) handleCategoryErr(w http.ResponseWriter, err error) bool { return h.handleErr(w, err) } +type brandInput struct { + Name string `json:"name"` +} + +type brandMergeInput struct { + TargetID string `json:"target_id"` +} + +// CreateBrand adds a brand. +func (h *Handler) CreateBrand(w http.ResponseWriter, r *http.Request) { + var in brandInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空") + return + } + b, err := h.store.CreateBrand(r.Context(), auth.UserFrom(r.Context()), in.Name) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, b) +} + +// UpdateBrand renames a brand. +func (h *Handler) UpdateBrand(w http.ResponseWriter, r *http.Request) { + var in brandInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空") + return + } + b, err := h.store.UpdateBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in.Name) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusOK, b) +} + +// MergeBrands folds one brand's products into another, then deletes the source. +func (h *Handler) MergeBrands(w http.ResponseWriter, r *http.Request) { + var in brandMergeInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.TargetID) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "请选择合并目标品牌") + return + } + b, err := h.store.MergeBrands(r.Context(), chi.URLParam(r, "id"), in.TargetID, auth.UserFrom(r.Context())) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusOK, b) +} + +// DeleteBrand removes a brand no product references. +func (h *Handler) DeleteBrand(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context())) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// handleBrandErr maps brand-specific errors to client statuses. +func (h *Handler) handleBrandErr(w http.ResponseWriter, err error) bool { + if err == nil { + return false + } + switch { + case errors.Is(err, adminstore.ErrDuplicateBrand): + writeError(w, http.StatusConflict, "duplicate_brand", "该品牌名称已存在") + return true + case errors.Is(err, adminstore.ErrBrandInUse): + writeError(w, http.StatusConflict, "in_use", "仍有商品使用该品牌,请先改用其它品牌或合并") + return true + case errors.Is(err, adminstore.ErrInvalidMerge): + writeError(w, http.StatusBadRequest, "invalid_merge", "合并目标无效(不存在或与源品牌相同)") + return true + } + return h.handleErr(w, err) +} + // ---------- submissions ---------- // CreateSubmission accepts an anonymous public contribution into the queue. diff --git a/api/internal/adminstore/brand.go b/api/internal/adminstore/brand.go new file mode 100644 index 0000000..fea10d8 --- /dev/null +++ b/api/internal/adminstore/brand.go @@ -0,0 +1,154 @@ +package adminstore + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Brand-management errors, mapped to client statuses by the handler. +var ( + // ErrDuplicateBrand is returned when a brand name already exists. + ErrDuplicateBrand = errors.New("duplicate brand name") + // ErrBrandInUse blocks deleting a brand still referenced by products. + ErrBrandInUse = errors.New("brand in use") + // ErrInvalidMerge is returned when a merge target is missing or equal to + // the source. + ErrInvalidMerge = errors.New("invalid merge target") +) + +// CreateBrand inserts a new brand. Names are unique by normalized form. +func (s *Store) CreateBrand(ctx context.Context, actor, name string) (*Brand, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name required") + } + var b Brand + err := s.pool.QueryRow(ctx, + `INSERT INTO brand (name, normalized_name) VALUES ($1, $2) RETURNING id, name, 0`, + name, normBrand(name)).Scan(&b.ID, &b.Name, &b.ProductCount) + if isUniqueViolation(err) { + return nil, ErrDuplicateBrand + } + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "create", "brand", &b.ID, []string{"name"}, nil, b) + return &b, nil +} + +// UpdateBrand renames a brand, keeping the normalized name in sync. +func (s *Store) UpdateBrand(ctx context.Context, id, actor, name string) (*Brand, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name required") + } + ct, err := s.pool.Exec(ctx, + "UPDATE brand SET name = $1, normalized_name = $2 WHERE id = $3", + name, normBrand(name), id) + if isUniqueViolation(err) { + return nil, ErrDuplicateBrand + } + if err != nil { + return nil, err + } + if ct.RowsAffected() == 0 { + return nil, ErrNotFound + } + out, err := s.getBrand(ctx, id) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "update", "brand", &id, []string{"name"}, nil, out) + return out, nil +} + +// MergeBrands reassigns every product of src to dst, then deletes src. Useful +// for collapsing duplicate brands (e.g. "可口可乐" and "Coca-Cola"). +func (s *Store) MergeBrands(ctx context.Context, srcID, dstID, actor string) (*Brand, error) { + if srcID == dstID { + return nil, ErrInvalidMerge + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var dstName string + err = tx.QueryRow(ctx, "SELECT name FROM brand WHERE id = $1", dstID).Scan(&dstName) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidMerge + } + if err != nil { + return nil, err + } + var srcName string + err = tx.QueryRow(ctx, "SELECT name FROM brand WHERE id = $1", srcID).Scan(&srcName) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, "UPDATE product SET brand_id = $1 WHERE brand_id = $2", dstID, srcID); err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, "DELETE FROM brand WHERE id = $1", srcID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + out, err := s.getBrand(ctx, dstID) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "merge", "brand", &srcID, []string{"name"}, + map[string]string{"name": srcName}, + map[string]string{"merged_into": dstName, "merged_into_id": dstID}) + return out, nil +} + +// DeleteBrand removes a brand not referenced by any product. +func (s *Store) DeleteBrand(ctx context.Context, id, actor string) error { + before, err := s.getBrand(ctx, id) + if err != nil { + return err + } + if before.ProductCount > 0 { + return fmt.Errorf("%w: %d products", ErrBrandInUse, before.ProductCount) + } + ct, err := s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", id) + if err != nil { + if isForeignKeyViolation(err) { + return ErrBrandInUse + } + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + _ = s.writeAudit(ctx, actor, "delete", "brand", &id, []string{"name"}, before, nil) + return nil +} + +func (s *Store) getBrand(ctx context.Context, id string) (*Brand, error) { + var b Brand + err := s.pool.QueryRow(ctx, ` +SELECT b.id, b.name, + (SELECT count(*) FROM product p WHERE p.brand_id = b.id) +FROM brand b WHERE b.id = $1`, id).Scan(&b.ID, &b.Name, &b.ProductCount) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &b, nil +} diff --git a/api/internal/adminstore/brand_test.go b/api/internal/adminstore/brand_test.go new file mode 100644 index 0000000..6be52e2 --- /dev/null +++ b/api/internal/adminstore/brand_test.go @@ -0,0 +1,78 @@ +package adminstore + +import ( + "context" + "errors" + "testing" +) + +func TestBrandLifecycle(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + a, err := s.CreateBrand(ctx, "tester", "品牌A "+randomHex(4)) + if err != nil { + t.Fatalf("create A: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", a.ID) }) + + b, err := s.CreateBrand(ctx, "tester", "品牌B "+randomHex(4)) + if err != nil { + t.Fatalf("create B: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", b.ID) }) + + // Duplicate (normalized) name must be rejected. + if _, err := s.CreateBrand(ctx, "tester", " "+a.Name+" "); !errors.Is(err, ErrDuplicateBrand) { + t.Fatalf("expected ErrDuplicateBrand, got %v", err) + } + + // Rename. + renamed, err := s.UpdateBrand(ctx, a.ID, "tester", "品牌A改名") + if err != nil { + t.Fatalf("rename: %v", err) + } + if renamed.Name != "品牌A改名" { + t.Fatalf("rename not applied: %q", renamed.Name) + } + + // Attach a product to brand A so deletion is blocked and merge moves it. + var prodID string + err = s.pool.QueryRow(ctx, + "INSERT INTO product (name, brand_id, status) VALUES ($1,$2,'active') RETURNING id", + "测试商品 "+randomHex(4), a.ID).Scan(&prodID) + if err != nil { + t.Fatalf("insert product: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM product WHERE id = $1", prodID) }) + + // Deleting an in-use brand must fail. + if err := s.DeleteBrand(ctx, a.ID, "tester"); !errors.Is(err, ErrBrandInUse) { + t.Fatalf("expected ErrBrandInUse, got %v", err) + } + + // Merge A into B: product reassigned, A deleted. + merged, err := s.MergeBrands(ctx, a.ID, b.ID, "tester") + if err != nil { + t.Fatalf("merge: %v", err) + } + if merged.ID != b.ID || merged.ProductCount < 1 { + t.Fatalf("merge result wrong: %+v", merged) + } + if _, err := s.getBrand(ctx, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("source brand should be gone, got %v", err) + } + + // Self-merge is invalid. + if _, err := s.MergeBrands(ctx, b.ID, b.ID, "tester"); !errors.Is(err, ErrInvalidMerge) { + t.Fatalf("expected ErrInvalidMerge, got %v", err) + } + + // After moving the product away from B, B can be deleted. + if _, err := s.pool.Exec(ctx, "DELETE FROM product WHERE id = $1", prodID); err != nil { + t.Fatalf("cleanup product: %v", err) + } + if err := s.DeleteBrand(ctx, b.ID, "tester"); err != nil { + t.Fatalf("delete unused brand: %v", err) + } +} diff --git a/api/internal/adminstore/write.go b/api/internal/adminstore/write.go index 238cdef..9a6a4dd 100644 --- a/api/internal/adminstore/write.go +++ b/api/internal/adminstore/write.go @@ -170,6 +170,79 @@ ON CONFLICT (product_id) DO UPDATE SET return after, nil } +// ErrDuplicateGTIN is returned when a product GTIN already exists. +var ErrDuplicateGTIN = errors.New("duplicate gtin") + +// CreateProduct inserts a new product from the admin UI. Only the core fields +// are required; the operator completes the rest in the detail editor. +func (s *Store) CreateProduct(ctx context.Context, actor string, in ProductInput) (*ProductDetail, error) { + if strings.TrimSpace(in.Name) == "" { + return nil, errors.New("name required") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + brandID := in.BrandID + if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" { + bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName)) + if err != nil { + return nil, err + } + brandID = &bid + } + + var gpc *string + if in.CategoryID != nil && *in.CategoryID != "" { + if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + } + + canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit) + if err != nil { + return nil, err + } + + status := in.Status + if status == "" { + status = "active" + } + + var id string + err = tx.QueryRow(ctx, ` +INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code, + net_content_value, net_content_unit, net_content_canonical, + country_of_origin, status) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) +RETURNING id`, + in.GTIN, in.Name, brandID, in.CategoryID, gpc, + in.NetContentValue, in.NetContentUnit, canonical, + in.CountryOfOrigin, status).Scan(&id) + if isUniqueViolation(err) { + return nil, ErrDuplicateGTIN + } + if err != nil { + return nil, err + } + + if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + after, err := s.GetProduct(ctx, id) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "create", "product", &id, []string{"name"}, nil, after) + return after, nil +} + func strEq(a, b *string) bool { if a == nil && b == nil { return true @@ -330,15 +403,21 @@ func (s *Store) DeleteMSRP(ctx context.Context, productID, msrpID, actor string) // ---------- dictionaries ---------- -// Brand is a brand option for the edit form. +// Brand is a brand option for the edit form and the management view. type Brand struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + ProductCount int `json:"product_count"` } -// ListBrands returns all brands ordered by name. +// ListBrands returns all brands ordered by name, with the number of products +// referencing each one. func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) { - rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY name") + rows, err := s.pool.Query(ctx, ` +SELECT b.id, b.name, + (SELECT count(*) FROM product p WHERE p.brand_id = b.id) AS product_count +FROM brand b +ORDER BY b.name`) if err != nil { return nil, err } @@ -346,7 +425,7 @@ func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) { out := []Brand{} for rows.Next() { var b Brand - if err := rows.Scan(&b.ID, &b.Name); err != nil { + if err := rows.Scan(&b.ID, &b.Name, &b.ProductCount); err != nil { return nil, err } out = append(out, b) -- 2.52.0 From f382c27200ea49e154d02f37041ed5e4d48eacf1 Mon Sep 17 00:00:00 2001 From: sulaimaannaasif6866 Date: Sun, 21 Jun 2026 01:45:28 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E6=A6=82?= =?UTF-8?q?=E8=A7=88/=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97/=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=93=8D=E4=BD=9C=20+=20=E9=A6=96=E9=A1=B5=E5=90=88?= =?UTF-8?q?=E6=A0=BC=E6=A1=A3=E6=A1=88=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /api/v1/stats 与后台 /api/stats、/api/audit、/api/products/bulk。合格口径=quality_score≥0.6 且在用。 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin-frontend/src/App.tsx | 39 ++++- admin-frontend/src/api.ts | 18 ++ .../src/components/AuditLogPage.tsx | 115 +++++++++++++ admin-frontend/src/components/ProductList.tsx | 155 +++++++++++++++++- admin-frontend/src/components/StatsPage.tsx | 125 ++++++++++++++ admin-frontend/src/types.ts | 21 +++ api/internal/adminhandler/handler.go | 73 +++++++++ api/internal/adminstore/bulk.go | 80 +++++++++ api/internal/adminstore/stats.go | 61 +++++++ api/internal/adminstore/stats_bulk_test.go | 71 ++++++++ api/internal/adminstore/write.go | 38 +++++ api/internal/handler/handler.go | 14 ++ api/internal/store/store.go | 18 ++ public-frontend/src/api.ts | 7 + public-frontend/src/components/Home.tsx | 21 ++- 15 files changed, 847 insertions(+), 9 deletions(-) create mode 100644 admin-frontend/src/components/AuditLogPage.tsx create mode 100644 admin-frontend/src/components/StatsPage.tsx create mode 100644 api/internal/adminstore/bulk.go create mode 100644 api/internal/adminstore/stats.go create mode 100644 api/internal/adminstore/stats_bulk_test.go diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 80db3cb..2b0ecfd 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -7,9 +7,18 @@ import SubmissionsPage from "./components/SubmissionsPage"; import ApiKeysPage from "./components/ApiKeysPage"; import CategoriesPage from "./components/CategoriesPage"; import BrandsPage from "./components/BrandsPage"; -import { FolderTree, Inbox, KeyRound, LogOut, Package, Tag } from "lucide-react"; +import StatsPage from "./components/StatsPage"; +import AuditLogPage from "./components/AuditLogPage"; +import { BarChart3, FolderTree, Inbox, KeyRound, LogOut, Package, ScrollText, Tag } from "lucide-react"; -type Tab = "products" | "submissions" | "categories" | "brands" | "keys"; +type Tab = + | "overview" + | "products" + | "submissions" + | "categories" + | "brands" + | "audit" + | "keys"; type View = { name: "list" } | { name: "detail"; id: string }; export default function App() { @@ -74,6 +83,16 @@ export default function App() { 天工商品档案公共仓 · 后台