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 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 元素在「录入 / 批量表」中展开的列(表头为营养成分名称)。 * 按模板固定表结构 + 自定义行出列;数值在 Label / Bulk Add 录入,不要求模板 config 里已有 value。 */ export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] { if (canonicalElementType(el.type) !== "NUTRITION") return []; const cfg = (el.config ?? {}) as Record; const specs: NutritionManualFieldSpec[] = [ { subKey: "servingsPerContainer", columnLabel: "Servings Per Container" }, { subKey: "servingSize", columnLabel: "Serving Size" }, { subKey: "calories", columnLabel: "Calories" }, ]; 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; 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) { specs.push({ subKey: item.key, columnLabel: item.label }); } } for (const ex of nutritionExtraRowsFromCfg(cfg)) { const id = String(ex.id ?? "").trim(); if (!id) 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 specs = listNutritionManualFieldSpecs(_el); const out: Record = {}; for (const s of specs) { out[s.subKey] = ""; } return out; } function pickManual(manual: Record, subKey: string): string { return String(manual[subKey] ?? "").trim(); } /** * 模板编辑器持久化:仅保留表结构(单位、自定义行名称),清除所有展示数值。 */ export function sanitizeNutritionTemplateConfig( cfg: Record, ): Record { const out: Record = { ...cfg }; out.calories = ""; out.servingsPerContainer = ""; out.servingSize = ""; delete out.Calories; delete out.ServingsPerContainer; delete out.ServingSize; const baseFixed = Array.isArray(out.fixedNutrients) ? (out.fixedNutrients as Record[]) : []; const fixedArr = NUTRITION_FIXED_ITEMS.map((item) => { const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === item.key); const unit = String(baseRow?.unit ?? item.defaultUnit ?? "").trim(); return { key: item.key, label: String(baseRow?.label ?? item.label), value: "", unit, }; }); out.fixedNutrients = fixedArr; for (const item of NUTRITION_FIXED_ITEMS) { out[item.key] = ""; const unit = fixedArr.find((r) => r.key === item.key)?.unit; if (unit) out[`${item.key}Unit`] = unit; else delete out[`${item.key}Unit`]; } out.extraNutrients = nutritionExtraRowsFromCfg(out).map((ex) => ({ id: ex.id, name: ex.name, value: "", unit: ex.unit, })); return out; } /** 模板编辑器:清除所有 NUTRITION 元素 config 中的展示数值 */ export function sanitizeNutritionElementsForTemplateEditor( elements: LabelElement[], ): LabelElement[] { return (elements ?? []).map((el) => { if (canonicalElementType(el.type) !== "NUTRITION") return el; return { ...el, config: sanitizeNutritionTemplateConfig((el.config ?? {}) as Record), }; }); } /** * 将手动录入合并进 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"); 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"); } else { cfg.servingsPerContainer = ""; delete cfg.ServingsPerContainer; } if (specSubKeys.has("servingSize")) { cfg.servingSize = pickManual(manual, "servingSize"); } 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); 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 }); /** LabelCanvas 先读顶层 key(如 fat),须与手动录入同步,否则会一直显示模板默认值 */ if (v) { cfg[s.subKey] = v; if (unit) cfg[`${s.subKey}Unit`] = unit; else delete cfg[`${s.subKey}Unit`]; } else { delete cfg[s.subKey]; delete cfg[`${s.subKey}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), 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; }