feat(admin): sortable product list column headers
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:
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user