import type { LabelElement } from "../types/labelTemplate"; import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate"; /** 批量表 / 与 elementId 拼接的字段名分隔(避免与普通 element id 冲突) */ export const NUTRITION_FIELD_COMPOSITE_SEP = "###nut###"; export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: string): string { return `${nutritionElementId}${NUTRITION_FIELD_COMPOSITE_SEP}${subKey}`; } export type NutritionManualFieldSpec = { subKey: string; columnLabel: string; }; function nutritionExtraRowsFromCfg(cfg: Record): Array<{ id: string; name: string; value: string; unit: string; }> { const raw = cfg.extraNutrients; if (!Array.isArray(raw)) return []; return raw.map((item, idx) => { const row = item as Record; return { id: String(row.id ?? `extra-${idx}`), name: String(row.name ?? ""), value: String(row.value ?? ""), unit: String(row.unit ?? ""), }; }); } function fixedLabelForKey(key: string): string { const hit = NUTRITION_FIXED_ITEMS.find((x) => x.key === key); return hit?.label ?? key; } function readFixedValueFromCfg(cfg: Record, key: string): string { const direct = cfg[key]; if (direct != null && String(direct).trim() !== "") return String(direct).trim(); const fixedRows = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const row = fixedRows.find((item) => String(item.key ?? "").trim() === key); return String(row?.value ?? "").trim(); } /** 与 LabelCanvas 营养成分展示一致:Calories 行是否应出现 */ function templateCaloriesDisplay(cfg: Record): string { return String(cfg.calories ?? cfg.Calories ?? readFixedValueFromCfg(cfg, "calories") ?? "").trim(); } function templateServingsPerContainer(cfg: Record): string { return String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? "").trim(); } function templateServingSize(cfg: Record): string { return String(cfg.servingSize ?? cfg.ServingSize ?? "").trim(); } function fakeNutritionElement(cfg: Record): LabelElement { return { id: "__nutrition_cfg__", type: "NUTRITION", x: 0, y: 0, width: 1, height: 1, rotation: "horizontal", border: "none", config: cfg, } as LabelElement; } /** * 模板中每个 NUTRITION 元素在「录入 / 批量表」中展开的列(表头为营养成分名称)。 * 仅包含模板里已配置非空展示值的项,与画布预览(LabelCanvas)营养成分表一致。 */ export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] { if (canonicalElementType(el.type) !== "NUTRITION") return []; const cfg = (el.config ?? {}) as Record; const specs: NutritionManualFieldSpec[] = []; if (templateCaloriesDisplay(cfg)) { specs.push({ subKey: "calories", columnLabel: "Calories" }); } if (templateServingsPerContainer(cfg)) { specs.push({ subKey: "servingsPerContainer", columnLabel: "Servings Per Container" }); } if (templateServingSize(cfg)) { specs.push({ subKey: "servingSize", columnLabel: "Serving Size" }); } const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const seen = new Set(); if (fixedArr.length > 0) { for (const row of fixedArr) { const key = String(row.key ?? "").trim(); if (!key || seen.has(key)) continue; const v = String(row.value ?? "").trim(); if (!v) continue; seen.add(key); const label = String(row.label ?? "").trim() || fixedLabelForKey(key); specs.push({ subKey: key, columnLabel: label }); } } else { for (const item of NUTRITION_FIXED_ITEMS) { const v = readFixedValueFromCfg(cfg, item.key).trim(); if (!v) continue; specs.push({ subKey: item.key, columnLabel: item.label }); } } for (const ex of nutritionExtraRowsFromCfg(cfg)) { const id = String(ex.id ?? "").trim(); if (!id) continue; const value = String(ex.value ?? "").trim(); if (!value) continue; const name = ex.name.trim() || "Other"; specs.push({ subKey: `extra:${id}:value`, columnLabel: name }); } return specs; } export function listNutritionElements(elements: LabelElement[]): LabelElement[] { return (elements ?? []).filter((el) => canonicalElementType(el.type) === "NUTRITION"); } /** 从模板 config 初始化手动录入 map */ export function nutritionManualValuesFromTemplateConfig(el: LabelElement): Record { const cfg = (el.config ?? {}) as Record; const out: Record = {}; const specs = listNutritionManualFieldSpecs(el); for (const s of specs) { if (s.subKey === "calories") { out.calories = String(cfg.calories ?? cfg.Calories ?? readFixedValueFromCfg(cfg, "calories") ?? "").trim(); continue; } if (s.subKey === "servingsPerContainer") { out.servingsPerContainer = String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? "").trim(); continue; } if (s.subKey === "servingSize") { out.servingSize = String(cfg.servingSize ?? cfg.ServingSize ?? "").trim(); continue; } if (s.subKey.startsWith("extra:") && s.subKey.endsWith(":value")) { const id = s.subKey.slice("extra:".length, -":value".length); const ex = nutritionExtraRowsFromCfg(cfg).find((r) => r.id === id); out[s.subKey] = (ex?.value ?? "").trim(); continue; } out[s.subKey] = readFixedValueFromCfg(cfg, s.subKey); } return out; } function pickManual( manual: Record, subKey: string, baseCfg: Record, ): string { const m = String(manual[subKey] ?? "").trim(); if (m !== "") return m; if (subKey === "calories") { return String(baseCfg.calories ?? baseCfg.Calories ?? readFixedValueFromCfg(baseCfg, "calories") ?? "").trim(); } if (subKey === "servingsPerContainer") { return String(baseCfg.servingsPerContainer ?? baseCfg.ServingsPerContainer ?? "").trim(); } if (subKey === "servingSize") { return String(baseCfg.servingSize ?? baseCfg.ServingSize ?? "").trim(); } if (subKey.startsWith("extra:") && subKey.endsWith(":value")) { const id = subKey.slice("extra:".length, -":value".length); const ex = nutritionExtraRowsFromCfg(baseCfg).find((r) => r.id === id); return (ex?.value ?? "").trim(); } return readFixedValueFromCfg(baseCfg, subKey); } /** * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。 * 输出中仅保留模板已声明的营养成分行,与 listNutritionManualFieldSpecs 一致。 */ export function mergeNutritionManualIntoConfig( baseCfg: Record, manual: Record, ): Record { const cfg: Record = { ...baseCfg }; const specs = listNutritionManualFieldSpecs(fakeNutritionElement(baseCfg)); const specSubKeys = new Set(specs.map((s) => s.subKey)); if (specSubKeys.has("calories")) { const cal = pickManual(manual, "calories", baseCfg); if (cal) cfg.calories = cal; else { delete cfg.calories; delete cfg.Calories; } } else { delete cfg.calories; delete cfg.Calories; } if (specSubKeys.has("servingsPerContainer")) { cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer", baseCfg); } else { cfg.servingsPerContainer = ""; delete cfg.ServingsPerContainer; } if (specSubKeys.has("servingSize")) { cfg.servingSize = pickManual(manual, "servingSize", baseCfg); } else { cfg.servingSize = ""; delete cfg.ServingSize; } const baseFixed = Array.isArray(baseCfg.fixedNutrients) ? (baseCfg.fixedNutrients as Record[]) : []; const fixedArr: Record[] = []; for (const s of specs) { if (["calories", "servingsPerContainer", "servingSize"].includes(s.subKey)) continue; if (s.subKey.startsWith("extra:")) continue; const v = pickManual(manual, s.subKey, baseCfg); const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === s.subKey); const unit = String( baseRow?.unit ?? NUTRITION_FIXED_ITEMS.find((x) => x.key === s.subKey)?.defaultUnit ?? "", ); const label = String(baseRow?.label ?? fixedLabelForKey(s.subKey)); fixedArr.push({ key: s.subKey, label, value: v, unit }); } cfg.fixedNutrients = fixedArr; const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = []; for (const s of specs) { if (!s.subKey.startsWith("extra:") || !s.subKey.endsWith(":value")) continue; const id = s.subKey.slice("extra:".length, -":value".length); const base = nutritionExtraRowsFromCfg(baseCfg).find((r) => r.id === id); newExtras.push({ id, name: (base?.name ?? s.columnLabel).trim() || "Other", value: pickManual(manual, s.subKey, baseCfg), unit: String(base?.unit ?? "").trim(), }); } cfg.extraNutrients = newExtras; return cfg; } export function serializeNutritionManualForDefaults(manual: Record): string { const o: Record = {}; for (const [k, v] of Object.entries(manual)) { const t = String(v ?? "").trim(); if (t !== "") o[k] = t; } return JSON.stringify(o); } export function parseNutritionManualFromDefaults(raw: string | undefined): Record { const t = String(raw ?? "").trim(); if (!t.startsWith("{")) return {}; try { const p = JSON.parse(t) as unknown; if (p == null || typeof p !== "object" || Array.isArray(p)) return {}; const out: Record = {}; for (const [k, v] of Object.entries(p as Record)) { out[k] = String(v ?? ""); } return out; } catch { return {}; } } export function nutritionDefaultValuesJsonForSave(manual: Record): string | null { const json = serializeNutritionManualForDefaults(manual); return json === "{}" ? null : json; } /** 从接口 defaultValues 展开营养成分 JSON 为批量表 composite 列键 */ export function hydrateRowFieldValuesWithNutritionColumns( defaultValues: Record, elements: LabelElement[], ): Record { const out = { ...defaultValues }; for (const nel of listNutritionElements(elements)) { const raw = out[nel.id]; if (typeof raw !== "string" || !raw.trim().startsWith("{")) continue; const parsed = parseNutritionManualFromDefaults(raw); delete out[nel.id]; const allowed = new Set(listNutritionManualFieldSpecs(nel).map((s) => s.subKey)); for (const [sk, val] of Object.entries(parsed)) { if (!allowed.has(sk)) continue; out[nutritionCompositeFieldKey(nel.id, sk)] = val; } } return out; } /** 将批量表 composite 列折叠回 defaultValues(元素 id → JSON) */ export function foldNutritionCompositeKeysIntoDefaults( fieldValues: Record, elements: LabelElement[], ): Record { const out: Record = { ...fieldValues }; for (const nel of listNutritionElements(elements)) { const specs = listNutritionManualFieldSpecs(nel); const manual: Record = {}; for (const s of specs) { const ck = nutritionCompositeFieldKey(nel.id, s.subKey); if (Object.prototype.hasOwnProperty.call(fieldValues, ck)) { manual[s.subKey] = fieldValues[ck] ?? ""; } } const prefix = `${nel.id}${NUTRITION_FIELD_COMPOSITE_SEP}`; for (const k of Object.keys(out)) { if (k.startsWith(prefix)) delete out[k]; } const j = nutritionDefaultValuesJsonForSave(manual); if (j) out[nel.id] = j; else delete out[nel.id]; } return out; }