import type { LabelElement } from "../types/labelTemplate"; import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate"; import { NUTRITION_FACTS_LAYOUT_ROWS, DEFAULT_NUTRITION_FOOTER_NOTE } from "./nutritionFactsLayout"; /** 批量表 / 与 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; inputType?: "text" | "checkbox"; }; 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_FACTS_LAYOUT_ROWS.find((x) => x.key === key) ?? 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 元素在「录入 / 批量表」中展开的列。 * 每行营养素固定两列录入:amount(含量含单位,自行填写如 11g)+ %DV。 */ 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" }, { subKey: "servingSize", columnLabel: "Serve size" }, { subKey: "calories", columnLabel: "Calories" }, ]; const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const labelByKey = new Map(); for (const item of NUTRITION_FACTS_LAYOUT_ROWS) { labelByKey.set(item.key, item.label); } for (const row of fixedArr) { const key = String(row.key ?? "").trim(); if (!key) continue; if (!labelByKey.has(key)) labelByKey.set(key, String(row.label ?? key)); else if (row.label) labelByKey.set(key, String(row.label)); } const keysInOrder: string[] = NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key); for (const row of fixedArr) { const key = String(row.key ?? "").trim(); if (key && !keysInOrder.includes(key)) keysInOrder.push(key); } for (const key of keysInOrder) { const label = labelByKey.get(key) ?? fixedLabelForKey(key); specs.push({ subKey: key, columnLabel: `${label} (amount)` }); specs.push({ subKey: `${key}Percent`, columnLabel: `${label} (% DV)` }); } 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} (amount)` }); specs.push({ subKey: `extra:${id}:percent`, columnLabel: `${name} (% DV)` }); } 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] = s.inputType === "checkbox" ? "false" : ""; } 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; delete out.ingredientsText; delete out.IngredientsText; for (const k of Object.keys(out)) { if (k.endsWith("Percent")) delete out[k]; } const baseFixed = Array.isArray(out.fixedNutrients) ? (out.fixedNutrients as Record[]) : []; const fixedArr = NUTRITION_FACTS_LAYOUT_ROWS.map((item) => { const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === item.key); const unit = String(baseRow?.unit ?? item.defaultUnit ?? "").trim(); const lessThan = Boolean(baseRow?.lessThan ?? out[`${item.key}LessThan`]); return { key: item.key, label: String(baseRow?.label ?? item.label), value: "", unit, dailyValuePercent: "", lessThan, }; }); out.fixedNutrients = fixedArr; for (const item of NUTRITION_FACTS_LAYOUT_ROWS) { out[item.key] = ""; const row = fixedArr.find((r) => r.key === item.key); if (row?.unit) out[`${item.key}Unit`] = row.unit; else delete out[`${item.key}Unit`]; out[`${item.key}LessThan`] = Boolean(row?.lessThan); } out.extraNutrients = nutritionExtraRowsFromCfg(out).map((ex) => ({ id: ex.id, name: ex.name, value: "", unit: ex.unit, })); if (!String(out.nutritionFooterNote ?? "").trim()) { out.nutritionFooterNote = DEFAULT_NUTRITION_FOOTER_NOTE; } 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 逻辑字段一致)。 */ 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; } } if (specSubKeys.has("servingsPerContainer")) { cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer"); } if (specSubKeys.has("servingSize")) { cfg.servingSize = pickManual(manual, "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; if (s.subKey.endsWith("Percent")) continue; const key = s.subKey; const v = pickManual(manual, key); const pct = pickManual(manual, `${key}Percent`); const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === key); const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`]); const label = String(baseRow?.label ?? fixedLabelForKey(key)); fixedArr.push({ key, label, value: v, unit: "", dailyValuePercent: pct, lessThan, }); if (v) cfg[key] = v; else { delete cfg[key]; delete cfg[`${key}Unit`]; } cfg[`${key}Percent`] = pct; cfg[`${key}LessThan`] = lessThan; } 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[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`); cfg[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`]); } 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; }