/** 分类按钮展示: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(); 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, ): 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; 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" }; }