categoryButtonAppearance.ts 10.8 KB
/** 分类按钮展示:TEXT / COLOR 可多选;IMAGE 必须单独出现 */

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 appearanceSelectionFromTokens(tokens: AppearanceToken[]): {
  text: boolean;
  color: boolean;
  image: boolean;
} {
  if (tokens.length === 1 && tokens[0] === "IMAGE") {
    return { text: false, color: false, image: true };
  }
  return {
    text: tokens.includes("TEXT"),
    color: tokens.includes("COLOR"),
    image: false,
  };
}

export function tokensFromSelection(sel: { text: boolean; color: boolean; image: boolean }): AppearanceToken[] {
  if (sel.image) return ["IMAGE"];
  const out: AppearanceToken[] = [];
  if (sel.text) out.push("TEXT");
  if (sel.color) out.push("COLOR");
  return out;
}

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 "";
}

export 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" };

/**
 * 根据 buttonAppearance(含 JSON 数组 / 单字符串)及扩展字段解析列表与 APP 的展示形态。
 */
/** 存库用:与后端 `buttonStyleJson` 对齐的 v1 结构 */
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);
}

/** 解析 `buttonStyleJson`;无法识别时返回 null,由调用方走旧字段回退 */
/** `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`(不再把 JSON 原文当作 legacy photo) */
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 数组的**字符串**(与 `categoryPhotoUrl` 存库形态一致),
 * 例如 `"[\"TEXT\",\"COLOR\"]"`,避免请求体里以 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;
  }
}

/** 列表/详情/树节点:优先 `buttonStyleJson`,否则回退扁平字段与旧数据 */
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" };
}