Files
goods/admin-frontend/src/components/ProductDetail.tsx
T
sulaimaannaasif6866 916bdd9c7c
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: 可扩展档案模式框架 + 内置 3C(电子) 模式
- 新增 category.archive_kind 与 kind_field 字段模板表(迁移 0010)
- 种子 electronics 字段模板 + 3C 品类树(手机/笔记本/平板等)
- 后端按档案模式动态计算完整度/合格:食品沿用 food_detail,
  其它模式走 product.attributes + kind_field
- 新增 GET /api/kind-fields?kind= 接口
- 后台编辑页按品类模式动态渲染规格参数表单
- 公开详情页/接口输出带标签的规格表

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

973 lines
30 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, useMemo, useState } from "react";
import { api } from "../api";
import {
AuditEntry,
Brand,
Category,
FIELD_LABELS,
KindField,
ProductDetail as Detail,
} from "../types";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Plus,
Save,
Trash2,
AlertCircle,
History,
Star,
} from "lucide-react";
const GTIN_TYPES = ["EAN13", "EAN8", "UPC", "ITF14", "GTIN14"];
const PACK_LEVELS: { value: string; label: string }[] = [
{ value: "each", label: "消费单元" },
{ value: "case", label: "箱" },
{ value: "pallet", label: "托盘" },
];
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: "删除建议零售价",
add_barcode: "新增条码",
delete_barcode: "删除条码",
set_primary_barcode: "设为主条码",
};
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,
ids = [],
onNavigate,
}: {
id: string;
onBack: () => void;
ids?: string[];
onNavigate?: (id: string) => void;
}) {
const navIndex = ids.indexOf(id);
const prevId = navIndex > 0 ? ids[navIndex - 1] : null;
const nextId =
navIndex >= 0 && navIndex < ids.length - 1 ? ids[navIndex + 1] : null;
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("");
const [kindFields, setKindFields] = useState<KindField[]>([]);
const [attrs, setAttrs] = useState<Record<string, string>>({});
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 || "");
const am: Record<string, string> = {};
if (detail.attributes) {
for (const [k, v] of Object.entries(detail.attributes)) {
am[k] = v == null ? "" : Array.isArray(v) ? v.join(", ") : String(v);
}
}
setAttrs(am);
}
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]);
const selectedKind = useMemo(() => {
const c = categories.find((x) => x.id === categoryId);
return c?.archive_kind || d?.archive_kind || "generic";
}, [categories, categoryId, d]);
useEffect(() => {
if (selectedKind && selectedKind !== "food") {
api
.listKindFields(selectedKind)
.then((r) => setKindFields(r.items))
.catch(() => setKindFields([]));
} else {
setKindFields([]);
}
}, [selectedKind]);
const specGroups = useMemo(() => {
const groups: { label: string; fields: KindField[] }[] = [];
for (const f of kindFields) {
let g = groups.find((x) => x.label === f.group_label);
if (!g) {
g = { label: f.group_label, fields: [] };
groups.push(g);
}
g.fields.push(f);
}
return groups;
}, [kindFields]);
const attrLabels = useMemo(() => {
const m: Record<string, string> = {};
for (const f of kindFields) m[f.field_key] = f.label_zh;
return m;
}, [kindFields]);
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;
}
let attributes: Record<string, unknown> | undefined;
if (selectedKind !== "food") {
attributes = {};
for (const f of kindFields) {
const raw = (attrs[f.field_key] ?? "").trim();
if (raw === "") continue;
if (f.field_type === "number") {
const n = parseFloat(raw);
if (!Number.isNaN(n)) attributes[f.field_key] = n;
} else if (f.field_type === "list") {
attributes[f.field_key] = parseList(raw);
} else {
attributes[f.field_key] = raw;
}
}
}
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,
...(attributes !== undefined ? { attributes } : {}),
};
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">
<div className="flex items-center gap-2">
<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>
{ids.length > 1 && navIndex >= 0 && (
<div className="ml-2 flex items-center gap-1 text-sm">
<button
onClick={() => prevId && onNavigate?.(prevId)}
disabled={!prevId}
className="flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs text-gray-400">
{navIndex + 1} / {ids.length}
</span>
<button
onClick={() => nextId && onNavigate?.(nextId)}
disabled={!nextId}
className="flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
<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] || attrLabels[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>
{selectedKind === "food" && (
<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>
)}
{selectedKind !== "food" && kindFields.length > 0 && (
<Card title="规格参数">
{specGroups.map((grp) => (
<div key={grp.label} className="mb-4 last:mb-0">
{grp.label && (
<h4 className="mb-2 text-xs font-medium text-gray-500">
{grp.label}
</h4>
)}
<div className="grid grid-cols-3 gap-3">
{grp.fields.map((f) => (
<Field
key={f.field_key}
label={f.unit ? `${f.label_zh} (${f.unit})` : f.label_zh}
>
{f.field_type === "select" ? (
<select
className={inputCls}
value={attrs[f.field_key] ?? ""}
onChange={(e) =>
setAttrs((prev) => ({
...prev,
[f.field_key]: e.target.value,
}))
}
>
<option value=""></option>
{f.options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
) : f.field_type === "textarea" ? (
<textarea
className={inputCls}
rows={3}
value={attrs[f.field_key] ?? ""}
onChange={(e) =>
setAttrs((prev) => ({
...prev,
[f.field_key]: e.target.value,
}))
}
/>
) : (
<input
className={inputCls}
type={f.field_type === "number" ? "number" : "text"}
step={f.field_type === "number" ? "any" : undefined}
placeholder={f.placeholder ?? undefined}
value={attrs[f.field_key] ?? ""}
onChange={(e) =>
setAttrs((prev) => ({
...prev,
[f.field_key]: e.target.value,
}))
}
/>
)}
</Field>
))}
</div>
</div>
))}
</Card>
)}
<BarcodesCard product={d} onChange={reload} onError={setError} />
<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 BarcodesCard({
product,
onChange,
onError,
}: {
product: Detail;
onChange: () => void;
onError: (m: string) => void;
}) {
const [gtin, setGtin] = useState("");
const [gtinType, setGtinType] = useState("EAN13");
const [packLevel, setPackLevel] = useState("each");
const [region, setRegion] = useState("");
const [busy, setBusy] = useState(false);
async function add() {
if (!gtin.trim()) return;
setBusy(true);
try {
await api.addBarcode(product.id, {
gtin: gtin.trim(),
gtin_type: gtinType,
pack_level: packLevel,
region: region.trim() || null,
is_primary: false,
});
setGtin("");
setRegion("");
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "添加失败");
} finally {
setBusy(false);
}
}
async function remove(barcodeId: string) {
try {
await api.deleteBarcode(product.id, barcodeId);
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "删除失败");
}
}
async function makePrimary(barcodeId: string) {
try {
await api.setPrimaryBarcode(product.id, barcodeId);
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "设置失败");
}
}
return (
<Card title="条码(一品多码,主条码镜像到 GTIN)">
<div className="mb-3 space-y-2">
{product.barcodes.length === 0 && (
<span className="text-sm text-gray-400"></span>
)}
{product.barcodes.map((b) => (
<div
key={b.id}
className="flex items-center gap-3 rounded border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
>
<button
onClick={() => !b.is_primary && makePrimary(b.id)}
title={b.is_primary ? "主条码" : "设为主条码"}
disabled={b.is_primary}
className={
b.is_primary
? "text-amber-500"
: "text-gray-300 hover:text-amber-500"
}
>
<Star
className="h-4 w-4"
fill={b.is_primary ? "currentColor" : "none"}
/>
</button>
<span className="font-mono font-medium text-gray-800">
{b.gtin}
</span>
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-[11px] text-gray-600">
{b.gtin_type}
</span>
<span className="text-gray-500">
{PACK_LEVELS.find((p) => p.value === b.pack_level)?.label ||
b.pack_level}
</span>
<span className="flex-1 text-gray-400">{b.region || ""}</span>
<button
onClick={() => remove(b.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="条码 (GTIN)">
<input
className="w-44 rounded border border-gray-300 px-3 py-2 text-sm"
value={gtin}
onChange={(e) => setGtin(e.target.value)}
placeholder="8/12/13/14 位"
/>
</Field>
<Field label="类型">
<select
className="rounded border border-gray-300 px-2 py-2 text-sm"
value={gtinType}
onChange={(e) => setGtinType(e.target.value)}
>
{GTIN_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Field>
<Field label="包装层级">
<select
className="rounded border border-gray-300 px-2 py-2 text-sm"
value={packLevel}
onChange={(e) => setPackLevel(e.target.value)}
>
{PACK_LEVELS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</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>
<button
onClick={add}
disabled={busy}
className="flex items-center gap-1 rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-60"
>
<Plus className="h-4 w-4" />
</button>
</div>
</Card>
);
}
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>
);
}