feat(admin): 运营后台(登录/查看/审核编辑/补全)+ 写入API + 审计留痕
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { api, setToken } from "../api";
|
||||
import { Package } from "lucide-react";
|
||||
|
||||
export default function Login({
|
||||
onLoggedIn,
|
||||
}: {
|
||||
onLoggedIn: (username: string) => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.login(username, password);
|
||||
setToken(r.token);
|
||||
onLoggedIn(r.username);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="w-80 rounded-xl bg-white p-8 shadow-md"
|
||||
>
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<Package className="h-8 w-8 text-emerald-600" />
|
||||
<h1 className="text-lg font-semibold text-gray-800">
|
||||
OpenGoods 管理后台
|
||||
</h1>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-4 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1 block text-sm text-gray-600">用户名</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="mb-5 block">
|
||||
<span className="mb-1 block text-sm text-gray-600">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded bg-emerald-600 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import {
|
||||
AuditEntry,
|
||||
Brand,
|
||||
Category,
|
||||
FIELD_LABELS,
|
||||
ProductDetail as Detail,
|
||||
} from "../types";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
|
||||
const NUTRIMENT_KEYS: { key: string; label: string }[] = [
|
||||
{ key: "energy_kcal", label: "能量 (kcal)" },
|
||||
{ key: "energy_kj", label: "能量 (kJ)" },
|
||||
{ key: "fat", label: "脂肪 (g)" },
|
||||
{ key: "saturated_fat", label: "饱和脂肪 (g)" },
|
||||
{ key: "carbohydrates", label: "碳水 (g)" },
|
||||
{ key: "sugars", label: "糖 (g)" },
|
||||
{ key: "proteins", label: "蛋白质 (g)" },
|
||||
{ key: "salt", label: "盐 (g)" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "active", label: "在用" },
|
||||
{ value: "merged", label: "已合并" },
|
||||
{ value: "deprecated", label: "已停用" },
|
||||
];
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
update: "编辑",
|
||||
add_image: "新增图片",
|
||||
delete_image: "删除图片",
|
||||
add_msrp: "新增建议零售价",
|
||||
delete_msrp: "删除建议零售价",
|
||||
};
|
||||
|
||||
function Card({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-gray-700">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-gray-500">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
|
||||
|
||||
export default function ProductDetail({
|
||||
id,
|
||||
onBack,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [d, setD] = useState<Detail | null>(null);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// editable form state
|
||||
const [name, setName] = useState("");
|
||||
const [gtin, setGtin] = useState("");
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [netValue, setNetValue] = useState("");
|
||||
const [netUnit, setNetUnit] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [ingredients, setIngredients] = useState("");
|
||||
const [allergens, setAllergens] = useState("");
|
||||
const [additives, setAdditives] = useState("");
|
||||
const [nutriments, setNutriments] = useState<Record<string, string>>({});
|
||||
const [basis, setBasis] = useState("");
|
||||
const [serving, setServing] = useState("");
|
||||
const [nutriScore, setNutriScore] = useState("");
|
||||
|
||||
function hydrate(detail: Detail) {
|
||||
setD(detail);
|
||||
setName(detail.name);
|
||||
setGtin(detail.gtin || "");
|
||||
setBrandName(detail.brand || "");
|
||||
setCategoryId(detail.category_id || "");
|
||||
setNetValue(detail.net_content_value?.toString() || "");
|
||||
setNetUnit(detail.net_content_unit || "");
|
||||
setCountry(detail.country_of_origin || "");
|
||||
setStatus(detail.status);
|
||||
setIngredients(detail.ingredients_text || "");
|
||||
setAllergens(detail.allergens.join(", "));
|
||||
setAdditives(detail.additives.join(", "));
|
||||
const nm: Record<string, string> = {};
|
||||
if (detail.nutriments) {
|
||||
for (const [k, v] of Object.entries(detail.nutriments)) nm[k] = String(v);
|
||||
}
|
||||
setNutriments(nm);
|
||||
setBasis(detail.nutrition_basis || "");
|
||||
setServing(detail.serving_size || "");
|
||||
setNutriScore(detail.nutri_score || "");
|
||||
}
|
||||
|
||||
function reload() {
|
||||
setLoading(true);
|
||||
Promise.all([api.getProduct(id), api.listAudit(id)])
|
||||
.then(([detail, a]) => {
|
||||
hydrate(detail);
|
||||
setAudit(a.items);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
api.listBrands().then((r) => setBrands(r.items)).catch(() => {});
|
||||
api.listCategories().then((r) => setCategories(r.items)).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const missing = useMemo(() => d?.missing ?? [], [d]);
|
||||
|
||||
function parseList(s: string): string[] {
|
||||
return s
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMsg("");
|
||||
setError("");
|
||||
const nm: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(nutriments)) {
|
||||
const n = parseFloat(v);
|
||||
if (!Number.isNaN(n)) nm[k] = n;
|
||||
}
|
||||
const body = {
|
||||
gtin: gtin.trim() || null,
|
||||
name: name.trim(),
|
||||
brand_name: brandName.trim() || null,
|
||||
brand_id: brandName.trim() ? undefined : null,
|
||||
category_id: categoryId || null,
|
||||
net_content_value: netValue.trim() ? parseFloat(netValue) : null,
|
||||
net_content_unit: netUnit.trim() || null,
|
||||
country_of_origin: country.trim() || null,
|
||||
status,
|
||||
ingredients_text: ingredients.trim() || null,
|
||||
allergens: parseList(allergens),
|
||||
additives: parseList(additives),
|
||||
nutriments: nm,
|
||||
nutrition_basis: basis || null,
|
||||
serving_size: serving.trim() || null,
|
||||
nutri_score: nutriScore || null,
|
||||
};
|
||||
try {
|
||||
const updated = await api.updateProduct(id, body);
|
||||
hydrate(updated);
|
||||
const a = await api.listAudit(id);
|
||||
setAudit(a.items);
|
||||
setMsg("已保存");
|
||||
setTimeout(() => setMsg(""), 2500);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-400">加载中…</div>;
|
||||
}
|
||||
if (!d) {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onBack} className="text-emerald-600">
|
||||
返回
|
||||
</button>
|
||||
<p className="mt-4 text-red-600">{error || "未找到商品"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> 返回列表
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{msg && <span className="text-sm text-emerald-600">{msg}</span>}
|
||||
{error && <span className="text-sm text-red-600">{error}</span>}
|
||||
<span className="text-xs text-gray-400">
|
||||
质量分 {Math.round(d.quality_score * 100)}
|
||||
</span>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 rounded bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
<Save className="h-4 w-4" /> {saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missing.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
待补全字段:{missing.map((f) => FIELD_LABELS[f] || f).join("、")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="基础信息">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="名称 *">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="条码 (GTIN)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={gtin}
|
||||
onChange={(e) => setGtin(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="品牌(不存在将自动创建)">
|
||||
<input
|
||||
className={inputCls}
|
||||
list="brand-list"
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
/>
|
||||
<datalist id="brand-list">
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field label="品类">
|
||||
<select
|
||||
className={inputCls}
|
||||
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>
|
||||
</Field>
|
||||
<Field label="净含量">
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
step="any"
|
||||
value={netValue}
|
||||
onChange={(e) => setNetValue(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="净含量单位 (g/ml/cl…)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={netUnit}
|
||||
onChange={(e) => setNetUnit(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="产地">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="状态">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="配料与营养">
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<Field label="配料表">
|
||||
<textarea
|
||||
className={inputCls}
|
||||
rows={3}
|
||||
value={ingredients}
|
||||
onChange={(e) => setIngredients(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="过敏原(逗号分隔)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={allergens}
|
||||
onChange={(e) => setAllergens(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="添加剂(逗号分隔)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={additives}
|
||||
onChange={(e) => setAdditives(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="营养基准">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={basis}
|
||||
onChange={(e) => setBasis(e.target.value)}
|
||||
>
|
||||
<option value="">(未设置)</option>
|
||||
<option value="per_100g">每 100g</option>
|
||||
<option value="per_100ml">每 100ml</option>
|
||||
<option value="per_serving">每份</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="份量">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={serving}
|
||||
onChange={(e) => setServing(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Nutri-Score (A-E)">
|
||||
<input
|
||||
className={inputCls}
|
||||
maxLength={1}
|
||||
value={nutriScore}
|
||||
onChange={(e) =>
|
||||
setNutriScore(e.target.value.toUpperCase())
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{NUTRIMENT_KEYS.map((n) => (
|
||||
<Field key={n.key} label={n.label}>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
step="any"
|
||||
value={nutriments[n.key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNutriments((prev) => ({ ...prev, [n.key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ImagesCard
|
||||
product={d}
|
||||
onChange={reload}
|
||||
onError={setError}
|
||||
/>
|
||||
<MsrpCard product={d} onChange={reload} onError={setError} />
|
||||
|
||||
<Card title="操作记录">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无记录</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{audit.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-3 text-gray-600"
|
||||
>
|
||||
<History className="h-3.5 w-3.5 text-gray-400" />
|
||||
<span className="text-gray-400">{a.created_at}</span>
|
||||
<span className="font-medium text-gray-700">{a.actor}</span>
|
||||
<span>{ACTION_LABEL[a.action] || a.action}</span>
|
||||
{a.fields.length > 0 && (
|
||||
<span className="text-gray-400">
|
||||
[{a.fields.map((f) => FIELD_LABELS[f] || f).join("、")}]
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagesCard({
|
||||
product,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
product: Detail;
|
||||
onChange: () => void;
|
||||
onError: (m: string) => void;
|
||||
}) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [kind, setKind] = useState("front");
|
||||
|
||||
async function add() {
|
||||
if (!url.trim()) return;
|
||||
try {
|
||||
await api.addImage(product.id, url.trim(), kind);
|
||||
setUrl("");
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "添加失败");
|
||||
}
|
||||
}
|
||||
async function remove(imageId: string) {
|
||||
try {
|
||||
await api.deleteImage(product.id, imageId);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="图片(仅存 URL)">
|
||||
<div className="mb-3 flex flex-wrap gap-3">
|
||||
{product.images.length === 0 && (
|
||||
<span className="text-sm text-gray-400">暂无图片</span>
|
||||
)}
|
||||
{product.images.map((im) => (
|
||||
<div
|
||||
key={im.id}
|
||||
className="relative h-24 w-24 overflow-hidden rounded border border-gray-200"
|
||||
>
|
||||
<img
|
||||
src={im.url}
|
||||
alt={im.kind}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(im.id)}
|
||||
className="absolute right-1 top-1 rounded bg-black/50 p-1 text-white hover:bg-black/70"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="absolute bottom-0 left-0 bg-black/50 px-1 text-[10px] text-white">
|
||||
{im.kind}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="图片 URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="rounded border border-gray-300 px-2 py-2 text-sm"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
<option value="front">正面</option>
|
||||
<option value="ingredients">配料</option>
|
||||
<option value="nutrition">营养</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={add}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MsrpCard({
|
||||
product,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
product: Detail;
|
||||
onChange: () => void;
|
||||
onError: (m: string) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [currency, setCurrency] = useState("CNY");
|
||||
const [region, setRegion] = useState("CN");
|
||||
const [date, setDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
async function add() {
|
||||
const a = parseFloat(amount);
|
||||
if (Number.isNaN(a)) return;
|
||||
try {
|
||||
await api.addMsrp(product.id, {
|
||||
amount: a,
|
||||
currency,
|
||||
region,
|
||||
effective_date: date || null,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
setAmount("");
|
||||
setNote("");
|
||||
setDate("");
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "添加失败");
|
||||
}
|
||||
}
|
||||
async function remove(msrpId: string) {
|
||||
try {
|
||||
await api.deleteMsrp(product.id, msrpId);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="官方建议零售价(MSRP 快照,非售卖)">
|
||||
<div className="mb-3 space-y-2">
|
||||
{product.msrp.length === 0 && (
|
||||
<span className="text-sm text-gray-400">暂无记录</span>
|
||||
)}
|
||||
{product.msrp.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-3 rounded border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-800">
|
||||
{m.amount} {m.currency}
|
||||
</span>
|
||||
<span className="text-gray-500">{m.region}</span>
|
||||
<span className="text-gray-400">{m.effective_date || ""}</span>
|
||||
<span className="flex-1 text-gray-400">{m.note || ""}</span>
|
||||
<button
|
||||
onClick={() => remove(m.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field label="金额">
|
||||
<input
|
||||
className="w-28 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
type="number"
|
||||
step="any"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="币种">
|
||||
<input
|
||||
className="w-20 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="地区">
|
||||
<input
|
||||
className="w-20 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="生效日期">
|
||||
<input
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<input
|
||||
className="w-40 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
onClick={add}
|
||||
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>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { FIELD_LABELS, ProductRow } from "../types";
|
||||
import { Search, AlertCircle } 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("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
api
|
||||
.listProducts(q, page, size)
|
||||
.then((r) => {
|
||||
setRows(r.items);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [q, page, size]);
|
||||
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</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="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={7} className="px-4 py-8 text-center text-gray-400">
|
||||
加载中…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} 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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user