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>
This commit is contained in:
@@ -124,6 +124,10 @@ export const api = {
|
||||
}),
|
||||
deleteBrand: (id: string) =>
|
||||
request<{ status: string }>(`/brands/${id}`, { method: "DELETE" }),
|
||||
listKindFields: (kind: string) =>
|
||||
request<{ items: import("./types").KindField[]; kind: string }>(
|
||||
`/kind-fields?kind=${encodeURIComponent(kind)}`,
|
||||
),
|
||||
listCategories: () =>
|
||||
request<{ items: import("./types").Category[] }>("/categories"),
|
||||
createCategory: (body: import("./types").CategoryInput) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Brand,
|
||||
Category,
|
||||
FIELD_LABELS,
|
||||
KindField,
|
||||
ProductDetail as Detail,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -127,6 +128,8 @@ export default function ProductDetail({
|
||||
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);
|
||||
@@ -149,6 +152,13 @@ export default function ProductDetail({
|
||||
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() {
|
||||
@@ -171,6 +181,41 @@ export default function ProductDetail({
|
||||
|
||||
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(",")
|
||||
@@ -187,6 +232,22 @@ export default function ProductDetail({
|
||||
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(),
|
||||
@@ -204,6 +265,7 @@ export default function ProductDetail({
|
||||
nutrition_basis: basis || null,
|
||||
serving_size: serving.trim() || null,
|
||||
nutri_score: nutriScore || null,
|
||||
...(attributes !== undefined ? { attributes } : {}),
|
||||
};
|
||||
try {
|
||||
const updated = await api.updateProduct(id, body);
|
||||
@@ -284,7 +346,8 @@ export default function ProductDetail({
|
||||
{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("、")}
|
||||
待补全字段:
|
||||
{missing.map((f) => FIELD_LABELS[f] || attrLabels[f] || f).join("、")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -371,6 +434,7 @@ export default function ProductDetail({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selectedKind === "food" && (
|
||||
<Card title="配料与营养">
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<Field label="配料表">
|
||||
@@ -443,6 +507,75 @@ export default function ProductDetail({
|
||||
))}
|
||||
</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
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface ProductDetail {
|
||||
brand: string | null;
|
||||
category_id: string | null;
|
||||
category_path: string | null;
|
||||
archive_kind: string;
|
||||
attributes: Record<string, unknown>;
|
||||
net_content_value: number | null;
|
||||
net_content_unit: string | null;
|
||||
country_of_origin: string | null;
|
||||
@@ -77,9 +79,23 @@ export interface Category {
|
||||
level: number;
|
||||
parent_id: string | null;
|
||||
gpc_brick_code: string | null;
|
||||
archive_kind: string;
|
||||
product_count: number;
|
||||
}
|
||||
|
||||
export interface KindField {
|
||||
kind: string;
|
||||
field_key: string;
|
||||
group_label: string;
|
||||
label_zh: string;
|
||||
field_type: string;
|
||||
unit: string | null;
|
||||
options: string[];
|
||||
placeholder: string | null;
|
||||
sort_order: number;
|
||||
qualified: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryInput {
|
||||
name_zh: string;
|
||||
name_en?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user