feat(admin): 品牌管理 + 新建商品
品牌管理:列出品牌及引用商品数,支持改名、合并重复品牌(把源品牌的商品并入目标后删除源)、删除未被引用的品牌。新建商品:商品列表新增「新建商品」入口,填写名称/条码/品牌/品类后创建并进入详情页继续补全。 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { FIELD_LABELS, ProductRow } from "../types";
|
||||
import { Search, AlertCircle } from "lucide-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: "在用",
|
||||
@@ -37,6 +37,7 @@ export default function ProductList({
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -79,9 +80,26 @@ export default function ProductList({
|
||||
<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}
|
||||
@@ -176,3 +194,130 @@ export default function ProductList({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user