normalizePreviewTemplate.ts 12.1 KB
import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'

function asRecord(v: unknown): Record<string, unknown> {
  if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown>
  return {}
}

function normalizeConfig(raw: unknown): Record<string, unknown> {
  if (raw == null) return {}
  if (typeof raw === 'string') {
    const t = raw.trim()
    if (!t) return {}
    try {
      const parsed = JSON.parse(t) as unknown
      if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
        return { ...(parsed as Record<string, unknown>) }
      }
    } catch {
      return {}
    }
    return {}
  }
  const o = asRecord(raw)
  return { ...o }
}

/**
 * 落库/列表返回的 element 可能把 text、src、fontSize 等摊在根上,仅 `config` 为空或不全;
 * 合并进 config 后打印与预览才能读到样式与图片地址。
 */
function mergeFlatElementFieldsIntoConfig(
  e: Record<string, unknown>,
  cfg: Record<string, unknown>,
): Record<string, unknown> {
  const out = { ...cfg }
  const keys = [
    'text',
    'Text',
    'prefix',
    'Prefix',
    'suffix',
    'Suffix',
    'fontSize',
    'FontSize',
    'textAlign',
    'TextAlign',
    'fontFamily',
    'fontWeight',
    'color',
    'Color',
    'src',
    'Src',
    'url',
    'Url',
    'data',
    'Data',
    'value',
    'Value',
    'unit',
    'Unit',
    'format',
    'Format',
    'decimal',
    'Decimal',
    'inputType',
    'InputType',
    'offsetDays',
    'OffsetDays',
    'multipleOptionId',
    'MultipleOptionId',
    'multipleOptionName',
    'MultipleOptionName',
    'selectedOptionValues',
    'SelectedOptionValues',
    'errorLevel',
    'scaleMode',
    'showText',
    'placeholder',
    'Placeholder',
  ] as const
  for (const k of keys) {
    const existing = out[k]
    const has =
      existing !== undefined &&
      existing !== null &&
      !(typeof existing === 'string' && String(existing).trim() === '')
    if (has) continue
    const v = e[k]
    if (v !== undefined && v !== null && !(typeof v === 'string' && String(v).trim() === '')) {
      out[k] = v as unknown
    }
  }
  return out
}

const TEXT_PRODUCT_PLACEHOLDERS = new Set(['', '文本', 'text', 'Text', 'TEXT', 'Label', 'label'])

/**
 * Web 端设计器里 TEXT_PRODUCT 若为 FIXED 且占位「文本」,预览页用当前商品名覆盖(与列表传入的 productName 一致)。
 */
export function overlayProductNameOnPreviewTemplate(
  template: SystemLabelTemplate,
  productName: string | undefined
): SystemLabelTemplate {
  const name = (productName ?? '').trim()
  if (!name) return template
  const elements = (template.elements || []).map((el) => {
    const type = String(el.type || '').toUpperCase()
    if (type !== 'TEXT_PRODUCT') return el
    const raw = String((el.config as any)?.text ?? (el.config as any)?.Text ?? '').trim()
    if (raw && !TEXT_PRODUCT_PLACEHOLDERS.has(raw)) return el
    return {
      ...el,
      config: { ...(el.config || {}), text: name },
    }
  })
  return { ...template, elements }
}

/**
 * 解析接口 `labelSizeText`(如 `2"x2"`、`6.00x4.00cm`、`2.00*2.00"`)。
 * 无单位后缀时默认 **inch**(与历史 `2"x2"` 一致)。
 */
export function parseLabelSizeText(
  raw: string | null | undefined
): { width: number; height: number; unit: 'inch' | 'mm' | 'cm' | 'px' } | null {
  if (raw == null) return null
  let s = String(raw).trim()
  if (!s) return null
  s = s.replace(/["'\u201C\u201D\u2018\u2019\u2032\u2033]/g, '').replace(/\s+/g, '').toLowerCase()
  const m = s.match(/^(\d+(?:\.\d+)?)[*×x](\d+(?:\.\d+)?)(mm|cm|inch|in|px)?$/i)
  if (!m) return null
  const w = Number(m[1])
  const h = Number(m[2])
  if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null
  const suf = (m[3] || '').toLowerCase()
  let unit: 'inch' | 'mm' | 'cm' | 'px' = 'inch'
  if (suf === 'mm') unit = 'mm'
  else if (suf === 'cm') unit = 'cm'
  else if (suf === 'px') unit = 'px'
  else if (suf === 'in' || suf === 'inch') unit = 'inch'
  return { width: w, height: h, unit }
}

/**
 * 用 `labelSizeText` 覆盖模板物理宽高(解析失败则保持原模板)。
 * 注意:App 预览画布应以接口 **template.width / height / unit** 为准(见 preview 页),勿与本函数同时叠加强制覆盖。
 */
export function applyLabelSizeTextToTemplate(
  template: SystemLabelTemplate,
  labelSizeText: string | null | undefined
): SystemLabelTemplate {
  const parsed = parseLabelSizeText(labelSizeText)
  if (!parsed) return template
  return {
    ...template,
    unit: parsed.unit,
    width: parsed.width,
    height: parsed.height,
  }
}

/**
 * 从预览接口响应中取出 `templateProductDefaultValues`(elementId → 字符串,兼容 PascalCase / 嵌套 data)。
 */
export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: unknown): Record<string, string> {
  const tryLayer = (layer: unknown): Record<string, string> | null => {
    if (layer == null || typeof layer !== 'object' || Array.isArray(layer)) return null
    const L = layer as Record<string, unknown>
    const raw = L.templateProductDefaultValues ?? L.TemplateProductDefaultValues
    if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return null
    const out: Record<string, string> = {}
    for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
      if (v == null) out[k] = ''
      else if (typeof v === 'string') out[k] = v
      else if (typeof v === 'number' || typeof v === 'boolean') out[k] = String(v)
      else out[k] = JSON.stringify(v)
    }
    return out
  }

  if (payload == null || typeof payload !== 'object') return {}
  const r = payload as Record<string, unknown>
  const nested = r.data ?? r.Data
  const inner =
    nested != null && typeof nested === 'object' && !Array.isArray(nested)
      ? (nested as Record<string, unknown>)
      : null

  const a = inner ? tryLayer(inner) : null
  if (a && Object.keys(a).length > 0) return a
  const b = tryLayer(r)
  if (b && Object.keys(b).length > 0) return b
  const c = tryLayer(payload)
  return c && Object.keys(c).length > 0 ? c : {}
}

/**
 * 将平台录入的默认值合并进模板元素 config,供画布预览(键与 elements[].id 一致)。
 */
export function applyTemplateProductDefaultValuesToTemplate(
  template: SystemLabelTemplate,
  defaults: Record<string, string>
): SystemLabelTemplate {
  const keys = Object.keys(defaults)
  if (!keys.length) return template
  const elements = (template.elements || []).map((el) => {
    const byName = (el.elementName ?? '').trim()
    const v =
      defaults[el.id] ?? (byName ? defaults[byName] : undefined)
    if (v === undefined) return el
    const type = String(el.type || '').toUpperCase()
    const cfg = { ...(el.config || {}) } as Record<string, any>

    if (type === 'IMAGE' || type === 'LOGO') {
      cfg.src = v
      cfg.url = v
      cfg.Src = v
      cfg.Url = v
      return { ...el, config: cfg }
    }
    if (type === 'BARCODE' || type === 'QRCODE') {
      cfg.data = v
      cfg.Data = v
      return { ...el, config: cfg }
    }
    if (type === 'WEIGHT') {
      cfg.value = v
      cfg.Value = v
      cfg.text = v
      cfg.Text = v
      return { ...el, config: cfg }
    }

    cfg.text = v
    cfg.Text = v
    return { ...el, config: cfg }
  })
  return { ...template, elements }
}

function elementArrayLength (o: Record<string, unknown>): number {
  const a = o.elements ?? o.Elements
  return Array.isArray(a) ? a.length : 0
}

/**
 * 列表 `renderTemplateJson` / 接口 9 落库可能是:① 根上直接 elements;② 包在 `printInputJson`;③ 包在 `template`(对象或 JSON 字符串);
 * ④ 根上既有 `template: {}` 又有 `elements` 时,**不能**误用空 template 丢掉根级 elements(会导致重打无字、无图)。
 */
function pickTemplateRootRecord (payload: Record<string, unknown>): Record<string, unknown> {
  const unwrapTemplateKey = (raw: unknown): Record<string, unknown> | null => {
    if (raw == null) return null
    if (typeof raw === 'string') {
      const s = raw.trim()
      if (!s) return null
      try {
        const p = JSON.parse(s) as unknown
        if (p != null && typeof p === 'object' && !Array.isArray(p)) return p as Record<string, unknown>
      } catch {
        return null
      }
      return null
    }
    if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>
    return null
  }

  if (elementArrayLength(payload) > 0) return payload

  const pi = payload.printInputJson ?? payload.PrintInputJson
  if (pi != null && typeof pi === 'object' && !Array.isArray(pi)) {
    const p = pi as Record<string, unknown>
    if (elementArrayLength(p) > 0) return p
  }

  const nested =
    unwrapTemplateKey(payload.template ?? payload.Template) ?? asRecord(payload.template ?? payload.Template)
  if (elementArrayLength(nested) > 0) return nested

  if (pi != null && typeof pi === 'object' && !Array.isArray(pi)) return pi as Record<string, unknown>
  if (Object.keys(nested).length > 0) return nested
  return payload
}

/**
 * 将接口 8.2 返回的 template(或整段 DTO)规范为 SystemLabelTemplate,供打印适配器与预览画布使用。
 */
export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLabelTemplate | null {
  if (payload == null || typeof payload !== 'object') return null
  let root = payload as Record<string, unknown>
  const wrapped = root.data ?? root.Data
  if (wrapped != null && typeof wrapped === 'object' && !Array.isArray(wrapped)) {
    root = wrapped as Record<string, unknown>
  }
  const t = pickTemplateRootRecord(root)
  const elementsRaw = t.elements ?? t.Elements
  if (!Array.isArray(elementsRaw)) return null

  const elements: SystemTemplateElementBase[] = (elementsRaw as unknown[]).map((el, index) => {
    const e = asRecord(el)
    let cfg = normalizeConfig(
      e.config ?? e.Config ?? e.ConfigJson ?? e.configJson ?? e.ConfigString,
    )
    cfg = mergeFlatElementFieldsIntoConfig(e, cfg)
    const type = String(e.type ?? e.elementType ?? e.ElementType ?? 'TEXT_STATIC')
    const vst = e.valueSourceType ?? e.ValueSourceType
    const ik = e.inputKey ?? e.InputKey
    const en = e.elementName ?? e.ElementName
    return {
      id: String(e.id ?? e.Id ?? `el-${index}`),
      type,
      x: Number(e.x ?? e.posX ?? e.PosX ?? 0),
      y: Number(e.y ?? e.posY ?? e.PosY ?? 0),
      width: Number(e.width ?? e.Width ?? 0),
      height: Number(e.height ?? e.Height ?? 0),
      rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical',
      border: String(e.border ?? e.BorderType ?? e.borderType ?? 'none'),
      config: cfg as Record<string, any>,
      zIndex: Number(e.zIndex ?? e.ZIndex ?? 0),
      orderNum: Number(e.orderNum ?? e.OrderNum ?? index),
      valueSourceType: vst != null ? String(vst) : undefined,
      inputKey: ik != null && String(ik).trim() !== '' ? String(ik).trim() : undefined,
      elementName: en != null && String(en).trim() !== '' ? String(en).trim() : undefined,
    } as SystemTemplateElementBase & { zIndex: number; orderNum: number }
  })

  const unitRaw = String(t.unit ?? t.Unit ?? 'inch').toLowerCase()
  const unit = (unitRaw === 'mm' || unitRaw === 'cm' || unitRaw === 'px' ? unitRaw : 'inch') as
    | 'inch'
    | 'mm'
    | 'cm'
    | 'px'

  return {
    id: String(t.id ?? t.Id ?? 'preview'),
    name: String(t.name ?? t.Name ?? 'Label'),
    labelType: String(t.labelType ?? t.LabelType ?? ''),
    unit,
    width: Number(t.width ?? t.Width ?? 2),
    height: Number(t.height ?? t.Height ?? 2),
    appliedLocation: String(t.appliedLocation ?? t.AppliedLocation ?? 'ALL'),
    showRuler: !!(t.showRuler ?? t.ShowRuler),
    showGrid: !!(t.showGrid ?? t.ShowGrid),
    elements,
  }
}

export function sortElementsForPreview(
  elements: SystemTemplateElementBase[]
): SystemTemplateElementBase[] {
  return [...elements].sort((a, b) => {
    const za = Number((a as any).zIndex ?? 0)
    const zb = Number((b as any).zIndex ?? 0)
    if (za !== zb) return za - zb
    const oa = Number((a as any).orderNum ?? 0)
    const ob = Number((b as any).orderNum ?? 0)
    return oa - ob
  })
}