/** 分类按钮展示: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 结构 */ /** 手提端产品列表布局(可选,存于 buttonStyleJson.appList 或 categoryPhotoUrl v2 包装) */ export type AppListLayoutStyle = { gridColumns?: number; gapRpx?: number; cardPaddingRpx?: number; thumbAspectPercent?: number; panelPaddingRpx?: number; }; export type StoredCategoryButtonStyleV1 = { v: 1; appearances: AppearanceToken[]; displayText?: string | null; buttonBgColor?: string | null; buttonTextColor?: string | null; buttonImageUrl?: string | null; appList?: AppListLayoutStyle | 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) return null; try { const j = JSON.parse(raw) as unknown; if (Array.isArray(j)) return j.map((x) => (x == null ? "" : String(x))); if (j && typeof j === "object" && Array.isArray((j as { values?: unknown }).values)) { return (j as { values: unknown[] }).values.map((x) => (x == null ? "" : String(x))); } return null; } catch { return null; } } /** 存库:values 数组 + 可选 appList(与后端 CategoryAppearanceApiHelper 一致) */ export function serializeCategoryPhotoUrlPayload( appearances: AppearanceToken[], opts: { displayText: string; buttonBgColor: string; buttonImageUrl: string }, appList?: AppListLayoutStyle | null, ): string { const values: string[] = []; for (const t of appearances) { if (t === "TEXT") values.push(opts.displayText.trim()); else if (t === "COLOR") values.push(opts.buttonBgColor.trim()); else if (t === "IMAGE") values.push(opts.buttonImageUrl.trim()); } const hasAppList = appList && (appList.gridColumns != null || appList.gapRpx != null || appList.cardPaddingRpx != null || appList.thumbAspectPercent != null || appList.panelPaddingRpx != null); if (!hasAppList) return JSON.stringify(values); return JSON.stringify({ values, appList }); } /** 将类型数组与 `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; const appListRaw = o.appList ?? o.AppList; let appList: AppListLayoutStyle | null = null; if (appListRaw && typeof appListRaw === "object") { const a = appListRaw as Record; appList = {}; const gc = Number(a.gridColumns ?? a.GridColumns); if (Number.isFinite(gc) && gc >= 1 && gc <= 4) appList.gridColumns = Math.round(gc); const gap = Number(a.gapRpx ?? a.GapRpx); if (Number.isFinite(gap) && gap >= 0) appList.gapRpx = Math.round(gap); const cp = Number(a.cardPaddingRpx ?? a.CardPaddingRpx); if (Number.isFinite(cp) && cp >= 0) appList.cardPaddingRpx = Math.round(cp); const asp = Number(a.thumbAspectPercent ?? a.ThumbAspectPercent); if (Number.isFinite(asp) && asp > 0 && asp <= 200) appList.thumbAspectPercent = Math.round(asp); const pp = Number(a.panelPaddingRpx ?? a.PanelPaddingRpx); if (Number.isFinite(pp) && pp >= 0) appList.panelPaddingRpx = Math.round(pp); if (Object.keys(appList).length === 0) appList = 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, appList, }; } 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)); } /** 产品列表/打标树:与分类相同解析 buttonAppearance + categoryPhotoUrl,无配置时回退 productImageUrl */ export function resolveProductButtonVisualFromDto(row: { buttonStyleJson?: string | null; buttonAppearance?: unknown; displayText?: string | null; buttonBgColor?: string | null; buttonImageUrl?: string | null; buttonTextColor?: string | null; categoryPhotoUrl?: string | null; productName?: string | null; productImageUrl?: string | null; }): CategoryVisualRender { const v = resolveCategoryButtonVisualFromDto({ buttonStyleJson: row.buttonStyleJson, buttonAppearance: row.buttonAppearance, displayText: row.displayText, buttonBgColor: row.buttonBgColor, buttonImageUrl: row.buttonImageUrl, buttonTextColor: row.buttonTextColor, categoryPhotoUrl: row.categoryPhotoUrl, categoryName: row.productName, name: row.productName, }); if (v.mode !== "none") return v; const legacyImg = String(row.productImageUrl ?? "").trim(); if (legacyImg && looksLikeImageUrl(legacyImg)) return { mode: "image", imageUrl: legacyImg }; return { mode: "none" }; } 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" }; }