Blame view

美国版/Food Labeling Management Platform/src/lib/nutritionManualEntry.ts 11.7 KB
63289723   杨鑫   提交
1
2
  import type { LabelElement } from "../types/labelTemplate";
  import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate";
20554770   杨鑫   更新bug
3
  import { NUTRITION_FACTS_LAYOUT_ROWS, DEFAULT_NUTRITION_FOOTER_NOTE } from "./nutritionFactsLayout";
63289723   杨鑫   提交
4
5
6
7
8
9
10
11
12
13
14
  
  /** 批量表 / 与 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;
20554770   杨鑫   更新bug
15
    inputType?: "text" | "checkbox";
63289723   杨鑫   提交
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  };
  
  function nutritionExtraRowsFromCfg(cfg: Record<string, unknown>): 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<string, unknown>;
      return {
        id: String(row.id ?? `extra-${idx}`),
        name: String(row.name ?? ""),
        value: String(row.value ?? ""),
        unit: String(row.unit ?? ""),
      };
    });
  }
  
  function fixedLabelForKey(key: string): string {
20554770   杨鑫   更新bug
38
    const hit = NUTRITION_FACTS_LAYOUT_ROWS.find((x) => x.key === key) ?? NUTRITION_FIXED_ITEMS.find((x) => x.key === key);
63289723   杨鑫   提交
39
40
41
    return hit?.label ?? key;
  }
  
63289723   杨鑫   提交
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
  function fakeNutritionElement(cfg: Record<string, unknown>): LabelElement {
    return {
      id: "__nutrition_cfg__",
      type: "NUTRITION",
      x: 0,
      y: 0,
      width: 1,
      height: 1,
      rotation: "horizontal",
      border: "none",
      config: cfg,
    } as LabelElement;
  }
  
  /**
20554770   杨鑫   更新bug
57
58
   * 模板中每个 NUTRITION 元素在「录入 / 批量表」中展开的列。
   * 每行营养素固定两列录入:amount(含量含单位,自行填写如 11g)+ %DV。
63289723   杨鑫   提交
59
60
61
62
   */
  export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] {
    if (canonicalElementType(el.type) !== "NUTRITION") return [];
    const cfg = (el.config ?? {}) as Record<string, unknown>;
4cb354d4   杨鑫   提交
63
    const specs: NutritionManualFieldSpec[] = [
20554770   杨鑫   更新bug
64
65
      { subKey: "servingsPerContainer", columnLabel: "Servings" },
      { subKey: "servingSize", columnLabel: "Serve size" },
4cb354d4   杨鑫   提交
66
67
      { subKey: "calories", columnLabel: "Calories" },
    ];
63289723   杨鑫   提交
68
69
  
    const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : [];
20554770   杨鑫   更新bug
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
    const labelByKey = new Map<string, string>();
    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)` });
63289723   杨鑫   提交
91
92
93
94
95
    }
  
    for (const ex of nutritionExtraRowsFromCfg(cfg)) {
      const id = String(ex.id ?? "").trim();
      if (!id) continue;
63289723   杨鑫   提交
96
      const name = ex.name.trim() || "Other";
20554770   杨鑫   更新bug
97
98
      specs.push({ subKey: `extra:${id}:value`, columnLabel: `${name} (amount)` });
      specs.push({ subKey: `extra:${id}:percent`, columnLabel: `${name} (% DV)` });
63289723   杨鑫   提交
99
    }
20554770   杨鑫   更新bug
100
  
63289723   杨鑫   提交
101
102
103
104
105
106
107
    return specs;
  }
  
  export function listNutritionElements(elements: LabelElement[]): LabelElement[] {
    return (elements ?? []).filter((el) => canonicalElementType(el.type) === "NUTRITION");
  }
  
4cb354d4   杨鑫   提交
108
109
110
  /** 从模板 config 初始化手动录入 map(模板不含数值,初始均为空) */
  export function nutritionManualValuesFromTemplateConfig(_el: LabelElement): Record<string, string> {
    const specs = listNutritionManualFieldSpecs(_el);
63289723   杨鑫   提交
111
    const out: Record<string, string> = {};
63289723   杨鑫   提交
112
    for (const s of specs) {
20554770   杨鑫   更新bug
113
      out[s.subKey] = s.inputType === "checkbox" ? "false" : "";
63289723   杨鑫   提交
114
115
116
117
    }
    return out;
  }
  
4cb354d4   杨鑫   提交
118
119
120
121
122
  function pickManual(manual: Record<string, string>, subKey: string): string {
    return String(manual[subKey] ?? "").trim();
  }
  
  /**
20554770   杨鑫   更新bug
123
   * 模板编辑器持久化:仅保留表结构(单位、自定义行名称、页脚文案),清除所有展示数值。
4cb354d4   杨鑫   提交
124
125
126
127
128
129
130
131
132
133
134
   */
  export function sanitizeNutritionTemplateConfig(
    cfg: Record<string, unknown>,
  ): Record<string, unknown> {
    const out: Record<string, unknown> = { ...cfg };
    out.calories = "";
    out.servingsPerContainer = "";
    out.servingSize = "";
    delete out.Calories;
    delete out.ServingsPerContainer;
    delete out.ServingSize;
20554770   杨鑫   更新bug
135
136
137
138
139
    delete out.ingredientsText;
    delete out.IngredientsText;
    for (const k of Object.keys(out)) {
      if (k.endsWith("Percent")) delete out[k];
    }
4cb354d4   杨鑫   提交
140
141
142
143
  
    const baseFixed = Array.isArray(out.fixedNutrients)
      ? (out.fixedNutrients as Record<string, unknown>[])
      : [];
20554770   杨鑫   更新bug
144
    const fixedArr = NUTRITION_FACTS_LAYOUT_ROWS.map((item) => {
4cb354d4   杨鑫   提交
145
146
      const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === item.key);
      const unit = String(baseRow?.unit ?? item.defaultUnit ?? "").trim();
20554770   杨鑫   更新bug
147
      const lessThan = Boolean(baseRow?.lessThan ?? out[`${item.key}LessThan`]);
4cb354d4   杨鑫   提交
148
149
150
151
152
      return {
        key: item.key,
        label: String(baseRow?.label ?? item.label),
        value: "",
        unit,
20554770   杨鑫   更新bug
153
154
        dailyValuePercent: "",
        lessThan,
4cb354d4   杨鑫   提交
155
156
157
158
      };
    });
    out.fixedNutrients = fixedArr;
  
20554770   杨鑫   更新bug
159
    for (const item of NUTRITION_FACTS_LAYOUT_ROWS) {
4cb354d4   杨鑫   提交
160
      out[item.key] = "";
20554770   杨鑫   更新bug
161
162
      const row = fixedArr.find((r) => r.key === item.key);
      if (row?.unit) out[`${item.key}Unit`] = row.unit;
4cb354d4   杨鑫   提交
163
      else delete out[`${item.key}Unit`];
20554770   杨鑫   更新bug
164
      out[`${item.key}LessThan`] = Boolean(row?.lessThan);
63289723   杨鑫   提交
165
    }
4cb354d4   杨鑫   提交
166
167
168
169
170
171
172
  
    out.extraNutrients = nutritionExtraRowsFromCfg(out).map((ex) => ({
      id: ex.id,
      name: ex.name,
      value: "",
      unit: ex.unit,
    }));
20554770   杨鑫   更新bug
173
174
175
176
  
    if (!String(out.nutritionFooterNote ?? "").trim()) {
      out.nutritionFooterNote = DEFAULT_NUTRITION_FOOTER_NOTE;
    }
4cb354d4   杨鑫   提交
177
178
179
180
181
182
183
184
185
186
187
188
189
190
    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<string, unknown>),
      };
    });
63289723   杨鑫   提交
191
192
193
194
  }
  
  /**
   * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。
63289723   杨鑫   提交
195
196
197
198
199
200
201
202
203
204
   */
  export function mergeNutritionManualIntoConfig(
    baseCfg: Record<string, unknown>,
    manual: Record<string, string>,
  ): Record<string, unknown> {
    const cfg: Record<string, unknown> = { ...baseCfg };
    const specs = listNutritionManualFieldSpecs(fakeNutritionElement(baseCfg));
    const specSubKeys = new Set(specs.map((s) => s.subKey));
  
    if (specSubKeys.has("calories")) {
4cb354d4   杨鑫   提交
205
      const cal = pickManual(manual, "calories");
63289723   杨鑫   提交
206
207
208
209
210
      if (cal) cfg.calories = cal;
      else {
        delete cfg.calories;
        delete cfg.Calories;
      }
63289723   杨鑫   提交
211
212
213
    }
  
    if (specSubKeys.has("servingsPerContainer")) {
4cb354d4   杨鑫   提交
214
      cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer");
63289723   杨鑫   提交
215
    }
63289723   杨鑫   提交
216
    if (specSubKeys.has("servingSize")) {
4cb354d4   杨鑫   提交
217
      cfg.servingSize = pickManual(manual, "servingSize");
63289723   杨鑫   提交
218
219
220
221
222
223
    }
  
    const baseFixed = Array.isArray(baseCfg.fixedNutrients)
      ? (baseCfg.fixedNutrients as Record<string, unknown>[])
      : [];
    const fixedArr: Record<string, unknown>[] = [];
20554770   杨鑫   更新bug
224
  
63289723   杨鑫   提交
225
    for (const s of specs) {
20554770   杨鑫   更新bug
226
227
228
      if (["calories", "servingsPerContainer", "servingSize"].includes(s.subKey)) {
        continue;
      }
63289723   杨鑫   提交
229
      if (s.subKey.startsWith("extra:")) continue;
20554770   杨鑫   更新bug
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
      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`];
91821909   杨鑫   最新
250
      }
20554770   杨鑫   更新bug
251
252
      cfg[`${key}Percent`] = pct;
      cfg[`${key}LessThan`] = lessThan;
63289723   杨鑫   提交
253
254
255
256
257
258
259
260
261
262
263
    }
    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",
4cb354d4   杨鑫   提交
264
        value: pickManual(manual, s.subKey),
63289723   杨鑫   提交
265
266
        unit: String(base?.unit ?? "").trim(),
      });
20554770   杨鑫   更新bug
267
268
      cfg[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`);
      cfg[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`]);
63289723   杨鑫   提交
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
    }
    cfg.extraNutrients = newExtras;
    return cfg;
  }
  
  export function serializeNutritionManualForDefaults(manual: Record<string, string>): string {
    const o: Record<string, string> = {};
    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<string, string> {
    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<string, string> = {};
      for (const [k, v] of Object.entries(p as Record<string, unknown>)) {
        out[k] = String(v ?? "");
      }
      return out;
    } catch {
      return {};
    }
  }
  
  export function nutritionDefaultValuesJsonForSave(manual: Record<string, string>): string | null {
    const json = serializeNutritionManualForDefaults(manual);
    return json === "{}" ? null : json;
  }
  
  /** 从接口 defaultValues 展开营养成分 JSON 为批量表 composite 列键 */
  export function hydrateRowFieldValuesWithNutritionColumns(
    defaultValues: Record<string, string>,
    elements: LabelElement[],
  ): Record<string, string> {
    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<string, string>,
    elements: LabelElement[],
  ): Record<string, string> {
    const out: Record<string, string> = { ...fieldValues };
    for (const nel of listNutritionElements(elements)) {
      const specs = listNutritionManualFieldSpecs(nel);
      const manual: Record<string, string> = {};
      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;
  }