916bdd9c7c
- 新增 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>
973 lines
30 KiB
TypeScript
973 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|