241fd38a56
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>
575 lines
18 KiB
TypeScript
575 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|