feat(admin): sortable product list column headers
CI / Go (api) (pull_request) Successful in 11s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s

Click a column header (名称/品牌/条码/品类/状态/质量分) to sort asc, click
again for desc, and a third time to clear back to the default
most-recently-updated order. Sort key/direction are whitelisted server-side.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sulaimaannaasif6866
2026-06-21 08:00:36 +00:00
parent 7434e5195e
commit 241fd38a56
4 changed files with 112 additions and 14 deletions
+11 -2
View File
@@ -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<import("./types").ProductDetail>(`/products/${id}`),
createProduct: (body: unknown) =>
+68 -9
View File
@@ -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<string, string> = {
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 (
<th className="px-4 py-3">
<button
type="button"
onClick={() => onSort(sortKey)}
className={`flex items-center gap-1 uppercase hover:text-gray-700 ${
active ? "text-emerald-600" : ""
}`}
>
{label}
{!active ? (
<ChevronsUpDown className="h-3.5 w-3.5 text-gray-300" />
) : order === "asc" ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
</th>
);
}
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<SortKey | "">("");
const [order, setOrder] = useState<"asc" | "desc">("asc");
const [jump, setJump] = useState("");
const [rows, setRows] = useState<ProductRow[]>([]);
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="全选"
/>
</th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<SortableTh label="名称" sortKey="name" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="品牌" sortKey="brand" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="条码" sortKey="gtin" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="品类" sortKey="category_path" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="状态" sortKey="status" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="质量分" sortKey="quality_score" sort={sort} order={order} onSort={toggleSort} />
<th className="px-4 py-3"></th>
</tr>
</thead>
+3 -1
View File
@@ -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
}
+30 -2
View File
@@ -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 {