categoryButtonAppearance.ts 9.46 KB
/** 与平台端 `categoryButtonAppearance.ts` 逻辑对齐,供 APP 列表与打标页渲染 */

export type AppearanceToken = 'TEXT' | 'COLOR' | 'IMAGE'

const ORDER: AppearanceToken[] = ['TEXT', 'COLOR', 'IMAGE']

export function parseAppearanceTokens(raw: unknown): AppearanceToken[] {
  if (raw == null) return []
  if (Array.isArray(raw)) {
    const seen = new Set<AppearanceToken>()
    for (const x of raw) {
      const u = String(x ?? '')
        .trim()
        .toUpperCase()
      if (u === 'TEXT' || u === 'COLOR' || u === 'IMAGE') seen.add(u)
    }
    return ORDER.filter((t) => seen.has(t))
  }
  const s = String(raw).trim()
  if (!s) return []
  if (s.startsWith('[')) {
    try {
      const j = JSON.parse(s) as unknown
      if (Array.isArray(j)) return parseAppearanceTokens(j)
    } catch {
      /* ignore */
    }
  }
  const u = s.toUpperCase()
  if (u === 'TEXT' || u === 'COLOR' || u === 'IMAGE') return [u]
  return []
}

export function normalizeHexColor(v: unknown): string {
  const raw = String(v ?? '').trim()
  if (!raw) return ''
  if (/^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(raw)) return raw
  if (/^([0-9a-f]{6}|[0-9a-f]{3})$/i.test(raw)) return `#${raw}`
  return ''
}

function looksLikeImageUrl(s: string): boolean {
  if (!s) return false
  if (/^https?:\/\//i.test(s)) return true
  if (s.startsWith('/')) return true
  if (/\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(s)) return true
  return false
}

export type CategoryVisualInput = {
  buttonAppearance?: unknown
  displayText?: string | null
  buttonBgColor?: string | null
  buttonImageUrl?: string | null
  buttonTextColor?: string | null
  categoryPhotoUrl?: string | null
  categoryName?: string | null
  name?: string | null
}

export type CategoryVisualRender =
  | { mode: 'image'; imageUrl: string }
  | { mode: 'colorText'; bg: string; text: string; textColor?: string | null }
  | { mode: 'color'; bg: string }
  | { mode: 'text'; text: string }
  | { mode: 'none' }

export type StoredCategoryButtonStyleV1 = {
  v: 1
  appearances: AppearanceToken[]
  displayText?: string | null
  buttonBgColor?: string | null
  buttonTextColor?: string | null
  buttonImageUrl?: string | null
}

export function serializeCategoryButtonStyleV1(input: {
  appearances: AppearanceToken[]
  displayText?: string | null
  buttonBgColor?: string | null
  buttonTextColor?: string | null
  buttonImageUrl?: string | null
}): string {
  const obj: StoredCategoryButtonStyleV1 = {
    v: 1,
    appearances: input.appearances,
    displayText: (input.displayText ?? '').trim() || null,
    buttonBgColor: (input.buttonBgColor ?? '').trim() || null,
    buttonTextColor: (input.buttonTextColor ?? '').trim() || null,
    buttonImageUrl: (input.buttonImageUrl ?? '').trim() || null,
  }
  return JSON.stringify(obj)
}

/** `categoryPhotoUrl` 存 JSON 数组:与 `buttonAppearance` 顺序一一对应(TEXT=文案,COLOR=色值,IMAGE=图片 URL) */
export function parseCategoryPhotoUrlValueArray(s: string | null | undefined): string[] | null {
  const raw = String(s ?? '').trim()
  if (!raw.startsWith('[')) return null
  try {
    const j = JSON.parse(raw) as unknown
    if (!Array.isArray(j)) return null
    return j.map((x) => (x == null ? '' : String(x)))
  } catch {
    return null
  }
}

/** 将类型数组与 `categoryPhotoUrl` 解析出的值数组合成 `CategoryVisualInput` */
export function visualInputFromAppearanceAndValueArray(
  appearances: AppearanceToken[],
  values: string[],
  row: Pick<CategoryDtoLike, 'categoryName' | 'name' | 'buttonTextColor'>,
): CategoryVisualInput {
  const merged: CategoryVisualInput = {
    buttonAppearance: appearances,
    categoryName: row.categoryName,
    name: row.name,
    buttonTextColor: row.buttonTextColor,
    categoryPhotoUrl: '',
  }
  const n = Math.min(appearances.length, values.length)
  for (let i = 0; i < n; i++) {
    const a = appearances[i]
    const v = values[i]?.trim() ?? ''
    if (a === 'TEXT') merged.displayText = v
    else if (a === 'COLOR') merged.buttonBgColor = normalizeHexColor(v) || v
    else if (a === 'IMAGE') merged.buttonImageUrl = v
  }
  return merged
}

/** 按 `appearances` 顺序生成写入 `categoryPhotoUrl` 的 JSON 数组字符串(与平台提交一致) */
export function serializeCategoryPhotoUrlValueArray(
  appearances: AppearanceToken[],
  opts: { displayText: string; buttonBgColor: string; buttonImageUrl: string },
): string {
  const vals: string[] = []
  for (const t of appearances) {
    if (t === 'TEXT') vals.push(opts.displayText.trim())
    else if (t === 'COLOR') vals.push(opts.buttonBgColor.trim())
    else if (t === 'IMAGE') vals.push(opts.buttonImageUrl.trim())
  }
  return JSON.stringify(vals)
}

/** 与平台端一致:接口入参 `buttonAppearance` 用 JSON 数组字符串 */
export function serializeButtonAppearanceForApi(raw: unknown): string | null {
  if (raw == null) return null
  if (Array.isArray(raw)) {
    const arr = raw.map((x) => String(x ?? '').trim()).filter((x) => x.length > 0)
    return arr.length === 0 ? null : JSON.stringify(arr)
  }
  const s = String(raw).trim()
  if (!s) return null
  if (s.startsWith('[')) {
    try {
      const j = JSON.parse(s) as unknown
      if (Array.isArray(j)) {
        const arr = j.map((x) => String(x ?? '').trim()).filter((x) => x.length > 0)
        return arr.length === 0 ? null : JSON.stringify(arr)
      }
    } catch {
      return s
    }
  }
  const u = s.toUpperCase()
  if (u === 'TEXT' || u === 'COLOR' || u === 'IMAGE') {
    return JSON.stringify([u])
  }
  return s
}

export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): StoredCategoryButtonStyleV1 | null {
  const raw = (jsonStr ?? '').trim()
  if (!raw) return null
  try {
    const o = JSON.parse(raw) as Record<string, unknown>
    if (!o || typeof o !== 'object') return null
    const appearances = parseAppearanceTokens(o.appearances ?? o.buttonAppearance)
    if (appearances.length === 0) return null
    return {
      v: 1,
      appearances,
      displayText: o.displayText != null ? String(o.displayText) : null,
      buttonBgColor: o.buttonBgColor != null ? String(o.buttonBgColor) : null,
      buttonTextColor: o.buttonTextColor != null ? String(o.buttonTextColor) : null,
      buttonImageUrl: o.buttonImageUrl != null ? String(o.buttonImageUrl) : null,
    }
  } catch {
    return null
  }
}

export type CategoryDtoLike = {
  buttonStyleJson?: string | null
  buttonAppearance?: unknown
  displayText?: string | null
  buttonBgColor?: string | null
  buttonTextColor?: string | null
  buttonImageUrl?: string | null
  categoryPhotoUrl?: string | null
  categoryName?: string | null
  name?: string | null
}

export function categoryVisualInputFromDto(row: CategoryDtoLike): CategoryVisualInput {
  const parsed = parseCategoryButtonStyleV1(row.buttonStyleJson)
  if (parsed) {
    return {
      buttonAppearance: parsed.appearances,
      displayText: parsed.displayText ?? row.displayText,
      buttonBgColor: parsed.buttonBgColor ?? row.buttonBgColor,
      buttonTextColor: parsed.buttonTextColor ?? row.buttonTextColor,
      buttonImageUrl: parsed.buttonImageUrl ?? row.buttonImageUrl,
      categoryPhotoUrl: row.categoryPhotoUrl,
      categoryName: row.categoryName,
      name: row.name,
    }
  }
  const appearances = parseAppearanceTokens(row.buttonAppearance)
  const valArr = parseCategoryPhotoUrlValueArray(row.categoryPhotoUrl)
  if (valArr && appearances.length > 0 && appearances.length === valArr.length) {
    return visualInputFromAppearanceAndValueArray(appearances, valArr, row)
  }
  return {
    buttonAppearance: row.buttonAppearance,
    displayText: row.displayText,
    buttonBgColor: row.buttonBgColor,
    buttonTextColor: row.buttonTextColor,
    buttonImageUrl: row.buttonImageUrl,
    categoryPhotoUrl: row.categoryPhotoUrl,
    categoryName: row.categoryName,
    name: row.name,
  }
}

export function resolveCategoryButtonVisualFromDto(row: CategoryDtoLike): CategoryVisualRender {
  return resolveCategoryButtonVisual(categoryVisualInputFromDto(row))
}

export function resolveCategoryButtonVisual(input: CategoryVisualInput): CategoryVisualRender {
  const tokens = parseAppearanceTokens(input.buttonAppearance)
  const categoryName = (input.categoryName ?? input.name ?? '').trim()
  const disp = (input.displayText ?? '').trim()
  const btnBg = normalizeHexColor(input.buttonBgColor)
  const btnImg = (input.buttonImageUrl ?? '').trim()
  const photo = String(input.categoryPhotoUrl ?? '').trim()

  if (tokens.includes('IMAGE')) {
    const url = btnImg || (photo && looksLikeImageUrl(photo) ? photo : '')
    if (url) return { mode: 'image', imageUrl: url }
    return { mode: 'none' }
  }

  const hasT = tokens.includes('TEXT')
  const hasC = tokens.includes('COLOR')

  let bg = btnBg
  if (!bg && hasC) bg = normalizeHexColor(photo)

  let textBody = disp
  if (hasT && !textBody && photo && !normalizeHexColor(photo) && !looksLikeImageUrl(photo)) {
    textBody = photo
  }
  const text = (textBody || categoryName).trim()

  if (hasT && hasC && bg) {
    return {
      mode: 'colorText',
      bg,
      text: text || categoryName,
      textColor: (input.buttonTextColor ?? '').trim() || null,
    }
  }
  if (hasC && bg) return { mode: 'color', bg }
  if (hasT && text) return { mode: 'text', text }

  if (tokens.length === 0) {
    const hex = normalizeHexColor(photo)
    if (hex) return { mode: 'color', bg: hex }
    if (photo && looksLikeImageUrl(photo)) return { mode: 'image', imageUrl: photo }
    if (photo) return { mode: 'text', text: photo }
    return { mode: 'none' }
  }

  return { mode: 'none' }
}