/** 与平台端 `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() 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, ): 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 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' } }