Files
goods/admin-frontend/src/components/ProductList.tsx
T
sulaimaannaasif6866 f382c27200
CI / Go (api) (pull_request) Successful in 13s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
feat: 数据概览/操作日志/批量操作 + 首页合格档案数
后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /api/v1/stats 与后台 /api/stats、/api/audit、/api/products/bulk。合格口径=quality_score≥0.6 且在用。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 01:45:28 +00:00

471 lines
15 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 } from "lucide-react";
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>
);
}
export default function ProductList({
onOpen,
}: {
onOpen: (id: string) => void;
}) {
const [q, setQ] = useState("");
const [input, setInput] = useState("");
const [page, setPage] = useState(1);
const [size] = useState(20);
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)
.then((r) => {
setRows(r.items);
setTotal(r.total);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}
useEffect(() => {
setSelected(new Set());
reload();
}, [q, page, size]);
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);
}}
/>
)}
{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>
<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>
<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)}
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 items-center justify-end gap-2 text-sm text-gray-600">
<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>
</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>
);
}