Files
goods/admin-frontend/src/components/ProductList.tsx
T
sulaimaannaasif6866 241fd38a56
CI / Go (api) (pull_request) Successful in 11s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
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>
2026-06-21 08:00:36 +00:00

575 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import { Brand, Category, FIELD_LABELS, ProductRow } from "../types";
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: "在用",
merged: "已合并",
deprecated: "已停用",
};
function QualityBadge({ score }: { score: number }) {
const pct = Math.round(score * 100);
const color =
score >= 0.8
? "bg-emerald-100 text-emerald-700"
: score >= 0.5
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700";
return (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>
{pct}
</span>
);
}
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,
}: {
onOpen: (id: string, ids: string[]) => void;
}) {
const [q, setQ] = useState("");
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);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [categories, setCategories] = useState<Category[]>([]);
const [bulkStatus, setBulkStatus] = useState("");
const [bulkCategory, setBulkCategory] = useState("");
const [bulkBusy, setBulkBusy] = useState(false);
function reload() {
setLoading(true);
setError("");
api
.listProducts(q, page, size, sort || undefined, order)
.then((r) => {
setRows(r.items);
setTotal(r.total);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}
useEffect(() => {
setSelected(new Set());
reload();
}, [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(() => {});
}, []);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
setSelected((prev) =>
prev.size === rows.length ? new Set() : new Set(rows.map((r) => r.id)),
);
}
async function applyBulkStatus() {
if (!bulkStatus || selected.size === 0) return;
setBulkBusy(true);
setError("");
try {
await api.bulkProducts({
ids: [...selected],
action: "status",
status: bulkStatus,
});
setSelected(new Set());
setBulkStatus("");
reload();
} catch (e) {
setError(e instanceof ApiError ? e.message : "批量操作失败");
} finally {
setBulkBusy(false);
}
}
async function applyBulkCategory() {
if (selected.size === 0) return;
setBulkBusy(true);
setError("");
try {
await api.bulkProducts({
ids: [...selected],
action: "category",
category_id: bulkCategory || null,
});
setSelected(new Set());
setBulkCategory("");
reload();
} catch (e) {
setError(e instanceof ApiError ? e.message : "批量操作失败");
} finally {
setBulkBusy(false);
}
}
const pages = Math.max(1, Math.ceil(total / size));
return (
<div className="mx-auto max-w-6xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800">
<span className="text-sm font-normal text-gray-400"> {total} </span>
</h2>
<form
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setQ(input.trim());
}}
className="flex items-center gap-2"
>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="按名称 / 条码搜索"
className="w-64 rounded border border-gray-300 py-2 pl-8 pr-3 text-sm focus:border-emerald-500 focus:outline-none"
/>
</div>
<button className="rounded bg-emerald-600 px-3 py-2 text-sm text-white hover:bg-emerald-700">
</button>
<button
type="button"
onClick={() => setCreating(true)}
className="flex items-center gap-1 rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800"
>
<Plus className="h-4 w-4" />
</button>
</form>
</div>
{creating && (
<CreateProductModal
onClose={() => setCreating(false)}
onCreated={(id) => {
setCreating(false);
onOpen(id, [id]);
}}
/>
)}
{error && (
<div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</div>
)}
{selected.size > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm">
<span className="font-medium text-emerald-800">
{selected.size}
</span>
<div className="flex items-center gap-1.5">
<span className="text-gray-500"></span>
<select
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
className="rounded border border-gray-300 bg-white px-2 py-1"
>
<option value=""></option>
<option value="active"></option>
<option value="deprecated"></option>
<option value="merged"></option>
</select>
<button
onClick={applyBulkStatus}
disabled={bulkBusy || !bulkStatus}
className="rounded bg-emerald-600 px-3 py-1 text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-500"></span>
<select
value={bulkCategory}
onChange={(e) => setBulkCategory(e.target.value)}
className="rounded border border-gray-300 bg-white px-2 py-1"
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{"\u00A0".repeat(c.level * 2)}
{c.name_zh}
</option>
))}
</select>
<button
onClick={applyBulkCategory}
disabled={bulkBusy}
className="rounded bg-emerald-600 px-3 py-1 text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
</div>
<button
onClick={() => setSelected(new Set())}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
)}
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
<tr>
<th className="w-10 px-4 py-3">
<input
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleAll}
aria-label="全选"
/>
</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>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : (
rows.map((r) => (
<tr
key={r.id}
onClick={() => onOpen(r.id, rows.map((x) => x.id))}
className={`cursor-pointer hover:bg-emerald-50/50 ${
selected.has(r.id) ? "bg-emerald-50/60" : ""
}`}
>
<td
className="px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={() => toggle(r.id)}
aria-label="选择"
/>
</td>
<td className="px-4 py-3 font-medium text-gray-800">{r.name}</td>
<td className="px-4 py-3 text-gray-600">{r.brand || "—"}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">
{r.gtin || "—"}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{r.category_path || "—"}
</td>
<td className="px-4 py-3 text-gray-600">
{STATUS_LABEL[r.status] || r.status}
</td>
<td className="px-4 py-3">
<QualityBadge score={r.quality_score} />
</td>
<td className="px-4 py-3">
{r.missing.length === 0 ? (
<span className="text-xs text-emerald-600"></span>
) : (
<span className="flex items-center gap-1 text-xs text-amber-600">
<AlertCircle className="h-3.5 w-3.5" />
{r.missing
.map((f) => FIELD_LABELS[f] || f)
.join("、")}
</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 text-sm text-gray-600">
<div className="mr-auto flex items-center gap-1">
<span></span>
<select
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
setPage(1);
}}
className="rounded border border-gray-300 px-2 py-1"
>
{[20, 50, 100].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<span> · {total} </span>
</div>
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
>
</button>
<span>
{page} / {pages}
</span>
<button
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
>
</button>
<form
onSubmit={(e) => {
e.preventDefault();
const n = Number(jump);
if (Number.isFinite(n) && n >= 1) {
setPage(Math.min(Math.max(1, Math.trunc(n)), pages));
setJump("");
}
}}
className="flex items-center gap-1"
>
<span></span>
<input
value={jump}
onChange={(e) => setJump(e.target.value.replace(/[^0-9]/g, ""))}
placeholder={String(page)}
className="w-14 rounded border border-gray-300 px-2 py-1 text-center"
aria-label="跳转页码"
/>
<button
type="submit"
className="rounded border border-gray-300 px-3 py-1 hover:bg-gray-50"
>
</button>
</form>
</div>
</div>
);
}
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<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
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 (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/30">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-lg">
<h3 className="mb-4 text-base font-semibold text-gray-800"></h3>
{error && (
<div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</div>
)}
<div className="space-y-3">
<label className="block">
<span className="text-xs text-gray-500"> *</span>
<input
autoFocus
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="商品名称"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"> (GTIN)</span>
<input
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
value={gtin}
onChange={(e) => setGtin(e.target.value)}
placeholder="8/12/13/14 位"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<input
list="create-brand-list"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
value={brand}
onChange={(e) => setBrand(e.target.value)}
/>
<datalist id="create-brand-list">
{brands.map((b) => (
<option key={b.id} value={b.name} />
))}
</datalist>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<select
className="mt-1 w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{"\u00A0".repeat(c.level * 2)}
{c.name_zh} ({c.path})
</option>
))}
</select>
</label>
</div>
<p className="mt-3 text-xs text-gray-400">
</p>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border px-4 py-2 text-sm text-gray-600"
>
</button>
<button
onClick={submit}
disabled={busy}
className="rounded bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-60"
>
{busy ? "创建中…" : "创建并编辑"}
</button>
</div>
</div>
</div>
);
}