diff --git a/admin-frontend/src/api.ts b/admin-frontend/src/api.ts index 16dbb38..5e8bafe 100644 --- a/admin-frontend/src/api.ts +++ b/admin-frontend/src/api.ts @@ -51,14 +51,23 @@ export const api = { body: JSON.stringify({ username, password }), }), me: () => request<{ username: string }>("/me"), - listProducts: (q: string, page: number, size: number) => + listProducts: ( + q: string, + page: number, + size: number, + sort?: string, + order?: string, + ) => request<{ items: import("./types").ProductRow[]; page: number; size: number; total: number; completeness_fields: string[]; - }>(`/products?q=${encodeURIComponent(q)}&page=${page}&size=${size}`), + }>( + `/products?q=${encodeURIComponent(q)}&page=${page}&size=${size}` + + (sort ? `&sort=${sort}&order=${order || "asc"}` : ""), + ), getProduct: (id: string) => request(`/products/${id}`), createProduct: (body: unknown) => diff --git a/admin-frontend/src/components/ProductList.tsx b/admin-frontend/src/components/ProductList.tsx index 278148f..3fbf136 100644 --- a/admin-frontend/src/components/ProductList.tsx +++ b/admin-frontend/src/components/ProductList.tsx @@ -1,7 +1,15 @@ import { useEffect, useState } from "react"; import { api, ApiError } from "../api"; import { Brand, Category, FIELD_LABELS, ProductRow } from "../types"; -import { Search, AlertCircle, Plus } from "lucide-react"; +import { Search, AlertCircle, Plus, ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react"; + +type SortKey = + | "name" + | "brand" + | "gtin" + | "category_path" + | "status" + | "quality_score"; const STATUS_LABEL: Record = { active: "在用", @@ -24,6 +32,42 @@ function QualityBadge({ score }: { score: number }) { ); } +function SortableTh({ + label, + sortKey, + sort, + order, + onSort, +}: { + label: string; + sortKey: SortKey; + sort: SortKey | ""; + order: "asc" | "desc"; + onSort: (key: SortKey) => void; +}) { + const active = sort === sortKey; + return ( + + + + ); +} + export default function ProductList({ onOpen, }: { @@ -33,6 +77,8 @@ export default function ProductList({ const [input, setInput] = useState(""); const [page, setPage] = useState(1); const [size, setSize] = useState(20); + const [sort, setSort] = useState(""); + const [order, setOrder] = useState<"asc" | "desc">("asc"); const [jump, setJump] = useState(""); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); @@ -49,7 +95,7 @@ export default function ProductList({ setLoading(true); setError(""); api - .listProducts(q, page, size) + .listProducts(q, page, size, sort || undefined, order) .then((r) => { setRows(r.items); setTotal(r.total); @@ -61,7 +107,20 @@ export default function ProductList({ useEffect(() => { setSelected(new Set()); reload(); - }, [q, page, size]); + }, [q, page, size, sort, order]); + + function toggleSort(key: SortKey) { + setPage(1); + if (sort !== key) { + setSort(key); + setOrder("asc"); + } else if (order === "asc") { + setOrder("desc"); + } else { + setSort(""); + setOrder("asc"); + } + } useEffect(() => { api.listCategories().then((r) => setCategories(r.items)).catch(() => {}); @@ -245,12 +304,12 @@ export default function ProductList({ aria-label="全选" /> - 名称 - 品牌 - 条码 - 品类 - 状态 - 质量分 + + + + + + 缺失字段 diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index eb39e01..5ab5ed7 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -164,8 +164,10 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { // ListProducts returns a paginated product list. func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") + sort := r.URL.Query().Get("sort") + order := r.URL.Query().Get("order") page, size := pageParams(r) - items, total, err := h.store.ListProducts(r.Context(), q, size, (page-1)*size) + items, total, err := h.store.ListProducts(r.Context(), q, sort, order, size, (page-1)*size) if h.handleErr(w, err) { return } diff --git a/api/internal/adminstore/adminstore.go b/api/internal/adminstore/adminstore.go index b1bde53..106465f 100644 --- a/api/internal/adminstore/adminstore.go +++ b/api/internal/adminstore/adminstore.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "strconv" + "strings" "time" "github.com/jackc/pgx/v5" @@ -50,8 +51,34 @@ type ProductRow struct { UpdatedAt string `json:"updated_at"` } +// productSortColumns whitelists the sortable list columns, mapping the API sort +// key to a SQL expression. NULLs sort last regardless of direction. +var productSortColumns = map[string]string{ + "name": "p.name", + "brand": "b.name", + "gtin": "p.gtin", + "category_path": "c.path", + "status": "p.status", + "quality_score": "p.quality_score", + "updated_at": "p.updated_at", +} + +// productOrderBy returns a safe ORDER BY clause for the given sort key/direction, +// falling back to the default (most recently updated first) for unknown keys. +func productOrderBy(sort, order string) string { + col, ok := productSortColumns[sort] + if !ok { + return "p.updated_at DESC" + } + dir := "ASC" + if strings.EqualFold(order, "desc") { + dir = "DESC" + } + return col + " " + dir + " NULLS LAST, p.updated_at DESC" +} + // ListProducts returns a paginated, optionally name/gtin-filtered list. -func (s *Store) ListProducts(ctx context.Context, q string, limit, offset int) ([]ProductRow, int, error) { +func (s *Store) ListProducts(ctx context.Context, q, sort, order string, limit, offset int) ([]ProductRow, int, error) { args := []any{} where := "WHERE 1=1" if q != "" { @@ -84,7 +111,8 @@ FROM product p LEFT JOIN brand b ON b.id = p.brand_id LEFT JOIN category c ON c.id = p.category_id LEFT JOIN food_detail f ON f.product_id = p.id ` + where + - " ORDER BY p.updated_at DESC LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args)) + " ORDER BY " + productOrderBy(sort, order) + + " LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args)) rows, err := s.pool.Query(ctx, sql, args...) if err != nil {