f382c27200
后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /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>
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|