From 790267e3eba903b59e741f79b798005967c4a5e4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 17 Oct 2026 14:47:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution-banner):=20=E5=88=86=E9=94=80?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E8=BD=AE=E6=92=AD=E6=94=AF=E6=8C=81=20PC/?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=88=86=E5=9B=BE=E4=B8=8E=E7=83=AD?= =?UTF-8?q?=E5=8C=BA=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- distribution-admin/src/api.ts | 4 +- .../src/components/BannerVisualEditor.tsx | 631 ++++++++++++++++++ distribution-admin/src/homeBanner.ts | 254 +++++++ distribution-admin/src/pages/SiteSettings.tsx | 301 ++++++--- .../src/utils/imageTransparency.ts | 106 +++ distribution-admin/src/utils/imageUpload.ts | 121 ++++ docs/首页轮播图配置说明.md | 23 +- .../src/stores/distributionHomeBanner.spec.ts | 67 ++ frontend/src/stores/distributionSite.ts | 85 ++- 9 files changed, 1472 insertions(+), 120 deletions(-) create mode 100644 distribution-admin/src/components/BannerVisualEditor.tsx create mode 100644 distribution-admin/src/homeBanner.ts create mode 100644 distribution-admin/src/utils/imageTransparency.ts create mode 100644 distribution-admin/src/utils/imageUpload.ts diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index dfdb90a..c2fafe9 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { optimizeImageForUpload } from './utils/imageUpload' const TOKEN_KEY = 'hfb_dist_access_token' @@ -751,8 +752,9 @@ export async function deleteSiteAnnouncement(siteID: number, id: number): Promis } export async function uploadSiteImage(file: File, scene: string): Promise { + const uploadTarget = await optimizeImageForUpload(file, scene) const form = new FormData() - form.append('file', file) + form.append('file', uploadTarget) form.append('scene', scene) const resp = await api.post>('/admin/files/upload', form, { headers: { 'Content-Type': 'multipart/form-data' }, diff --git a/distribution-admin/src/components/BannerVisualEditor.tsx b/distribution-admin/src/components/BannerVisualEditor.tsx new file mode 100644 index 0000000..df90ade --- /dev/null +++ b/distribution-admin/src/components/BannerVisualEditor.tsx @@ -0,0 +1,631 @@ +import { useEffect, useRef, useState } from 'react' +import { + Button, + Input, + InputNumber, + Select, + Space, + Switch, + Typography, + Upload, + message, +} from 'antd' +import { DeleteOutlined, PictureOutlined, UploadOutlined } from '@ant-design/icons' +import { + BANNER_HOTSPOT_DEFAULT_HOVER_SCALE, + bannerActionHints, + bannerActionOptions, + createBannerHotspot, + type BannerAction, + type BannerActionType, + type BannerHotspot, + type BannerVisual, +} from '../homeBanner' +import { uploadSiteImage } from '../api' +import { probeImageTransparency } from '../utils/imageTransparency' + +interface Props { + value: BannerVisual + onChange: (value: BannerVisual) => void + ratio: { width: number; height: number } + label: string + standard: string +} + +type DragMode = 'move' | 'resize' + +interface DragState { + id: string + mode: DragMode + clientX: number + clientY: number + origin: BannerHotspot +} + +function clamp(value: number, min: number, max: number) { + return Math.round(Math.min(Math.max(value, min), Math.max(max, min)) * 100) / 100 +} + +export default function BannerVisualEditor({ value, onChange, ratio, label, standard }: Props) { + const [uploading, setUploading] = useState(false) + const [uploadingHotspot, setUploadingHotspot] = useState(false) + const [bannerWarning, setBannerWarning] = useState('') + const [hotspotWarning, setHotspotWarning] = useState('') + const [selectedId, setSelectedId] = useState('') + const canvasRef = useRef(null) + const valueRef = useRef(value) + const onChangeRef = useRef(onChange) + const dragRef = useRef(null) + const draggingRef = useRef(false) + + valueRef.current = value + onChangeRef.current = onChange + + const selectedHotspot = value.hotspots.find((item) => item.id === selectedId) ?? null + + function emit(patch: Partial) { + onChangeRef.current({ ...valueRef.current, ...patch }) + } + + function updateHotspot(id: string, patch: Partial) { + emit({ + hotspots: valueRef.current.hotspots.map((item) => + item.id === id ? { ...item, ...patch } : item, + ), + }) + } + + function patchSelected(patch: Partial) { + const target = valueRef.current.hotspots.find((item) => item.id === selectedId) + if (!target) return + updateHotspot(target.id, patch) + } + + async function handleBannerUpload(file: File) { + setUploading(true) + try { + const url = await uploadSiteImage(file, 'home-banner') + emit({ image_url: url }) + message.success('图片已上传') + await warnAboutBannerImage(file) + } catch { + message.error('图片上传失败') + } finally { + setUploading(false) + } + } + + async function warnAboutBannerImage(file: File) { + setBannerWarning('') + const probe = await probeImageTransparency(file) + if (!probe?.suspectedBakedBackdrop) return + setBannerWarning( + '这张图几乎只有白/浅灰两种颜色且没有透明区域,很像把「透明棋盘格」一起导出了,前台会照实显示。', + ) + message.warning('图片疑似带棋盘格底纹,请检查后重新导出') + } + + async function handleHotspotUpload(file: File) { + const target = valueRef.current.hotspots.find((item) => item.id === selectedId) + if (!target) return + setUploadingHotspot(true) + try { + const url = await uploadSiteImage(file, 'home-banner') + updateHotspot(target.id, { image_url: url }) + message.success('热区图片已上传') + await warnAboutHotspotImage(file) + } catch { + message.error('热区图片上传失败') + } finally { + setUploadingHotspot(false) + } + } + + async function warnAboutHotspotImage(file: File) { + setHotspotWarning('') + const probe = await probeImageTransparency(file) + if (!probe || probe.hasTransparentPixels) return + setHotspotWarning( + probe.suspectedBakedBackdrop + ? '这张图没有透明区域,且只有白/浅灰底色:很可能是把「透明棋盘格」一起导出了。请重新导出带透明通道的 PNG。' + : '这张图没有透明区域,会整块盖住海报背景;透明按钮图请用带 alpha 通道的 PNG。', + ) + message.warning('热区图片不带透明区域,请确认导出方式') + } + + function addHotspotAt(event: React.MouseEvent) { + if (draggingRef.current) return + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + if (!rect.width || !rect.height) return + const width = 24 + const height = 24 + const x = ((event.clientX - rect.left) / rect.width) * 100 + const y = ((event.clientY - rect.top) / rect.height) * 100 + const hotspot = createBannerHotspot({ + x: clamp(x - width / 2, 0, 100 - width), + y: clamp(y - height / 2, 0, 100 - height), + width, + height, + }) + emit({ hotspots: [...valueRef.current.hotspots, hotspot] }) + setSelectedId(hotspot.id) + setHotspotWarning('') + } + + function startDrag( + event: React.MouseEvent, + hotspot: BannerHotspot, + mode: DragMode, + ) { + event.preventDefault() + event.stopPropagation() + setSelectedId(hotspot.id) + dragRef.current = { + id: hotspot.id, + mode, + clientX: event.clientX, + clientY: event.clientY, + origin: { ...hotspot }, + } + window.addEventListener('mousemove', handleDragMove) + window.addEventListener('mouseup', stopDrag) + } + + function handleDragMove(event: MouseEvent) { + const state = dragRef.current + const canvas = canvasRef.current + if (!state || !canvas) return + const rect = canvas.getBoundingClientRect() + if (!rect.width || !rect.height) return + const deltaX = ((event.clientX - state.clientX) / rect.width) * 100 + const deltaY = ((event.clientY - state.clientY) / rect.height) * 100 + const target = valueRef.current.hotspots.find((item) => item.id === state.id) + if (!target) return + if (state.mode === 'move') { + updateHotspot(state.id, { + x: clamp(state.origin.x + deltaX, 0, 100 - target.width), + y: clamp(state.origin.y + deltaY, 0, 100 - target.height), + }) + return + } + updateHotspot(state.id, { + width: clamp(state.origin.width + deltaX, 4, 100 - target.x), + height: clamp(state.origin.height + deltaY, 4, 100 - target.y), + }) + } + + function stopDrag() { + if (!dragRef.current) return + draggingRef.current = true + window.removeEventListener('mousemove', handleDragMove) + window.removeEventListener('mouseup', stopDrag) + dragRef.current = null + window.setTimeout(() => { + draggingRef.current = false + }, 0) + } + + useEffect(() => { + return () => { + window.removeEventListener('mousemove', handleDragMove) + window.removeEventListener('mouseup', stopDrag) + } + }, []) + + function removeHotspot() { + if (!selectedHotspot) return + emit({ hotspots: value.hotspots.filter((item) => item.id !== selectedHotspot.id) }) + setSelectedId('') + } + + function updateAction(action: BannerAction) { + emit({ action }) + } + + function renderActionEditor(action: BannerAction, onActionChange: (next: BannerAction) => void) { + const needTarget = action.type === 'route' || action.type === 'link' + return ( + + + 跳转类型 + + value={action.type} + style={{ width: 130 }} + options={bannerActionOptions} + onChange={(type) => onActionChange({ ...action, type })} + /> + + {bannerActionHints[action.type]} + + + {needTarget && ( + <> + onActionChange({ ...action, target: event.target.value })} + /> + + onActionChange({ ...action, open_new_tab: checked })} + /> + 在新窗口打开 + + + )} + + ) + } + + return ( +
+
+ {label} + + 建议出图 {standard} + +
+ + + { + setBannerWarning('') + emit({ image_url: event.target.value }) + }} + /> + { + void handleBannerUpload(file) + return false + }} + > + + + + + {bannerWarning && ( + {bannerWarning} + )} + +
+ {value.image_url ? ( + + ) : ( +
+ 先上传图片,再在图上单击新增热区 +
+ )} + + {value.hotspots.map((hotspot, index) => ( +
{ + event.stopPropagation() + setSelectedId(hotspot.id) + setHotspotWarning('') + }} + onMouseDown={(event) => startDrag(event, hotspot, 'move')} + style={{ + position: 'absolute', + left: `${hotspot.x}%`, + top: `${hotspot.y}%`, + width: `${hotspot.width}%`, + height: `${hotspot.height}%`, + display: 'flex', + alignItems: 'flex-start', + gap: 4, + padding: '2px 4px', + border: `1px ${hotspot.id === selectedId ? 'solid' : 'dashed'} rgba(255, 106, 0, 0.85)`, + borderRadius: 6, + background: + hotspot.id === selectedId ? 'rgba(255, 106, 0, 0.32)' : 'rgba(255, 106, 0, 0.16)', + color: '#ffffff', + fontSize: 11, + lineHeight: 1.4, + cursor: 'move', + overflow: 'hidden', + }} + > + {hotspot.image_url && ( + + )} + + {index + 1} + + + {hotspot.label || '未命名热区'} + + startDrag(event, hotspot, 'resize')} + style={{ + position: 'absolute', + right: 0, + bottom: 0, + width: 12, + height: 12, + borderRight: '2px solid #ff6a00', + borderBottom: '2px solid #ff6a00', + background: 'rgba(255, 255, 255, 0.9)', + cursor: 'nwse-resize', + }} + /> +
+ ))} +
+ + 在预览图上单击可新增热区,按住拖动可移动,拖右下角小方块可缩放;坐标按百分比保存,换分辨率也不跑位。 + 热区按钮图必须是带透明通道的 PNG。 + + +
+ {renderActionEditor(value.action, updateAction)} + emit({ alt: event.target.value })} + /> +
+ +
+
+ 热区({value.hotspots.length}) + +
+ + {!value.hotspots.length ? ( + + 未配置热区时,整张图按上面的「整图点击」行为响应。 + + ) : ( + <> + + {value.hotspots.map((hotspot, index) => ( + + ))} + + + {selectedHotspot && ( +
+ patchSelected({ label: event.target.value })} + /> + + + { + setHotspotWarning('') + patchSelected({ image_url: event.target.value }) + }} + /> + { + void handleHotspotUpload(file) + return false + }} + > + + + + + {hotspotWarning && ( + + {hotspotWarning} + + )} + + + 悬浮放大 + + patchSelected({ + hover_scale: value ?? BANNER_HOTSPOT_DEFAULT_HOVER_SCALE, + }) + } + /> + + 1 = 不放大,建议 1.04-1.08 + + + + + 位置尺寸 + patchSelected({ x: value ?? 0 })} + /> + patchSelected({ y: value ?? 0 })} + /> + patchSelected({ width: value ?? 1 })} + /> + patchSelected({ height: value ?? 1 })} + /> + + 百分比(0-100) + + + + {renderActionEditor(selectedHotspot.action, (action) => + patchSelected({ action }), + )} + +
+ +
+
+ )} + + )} +
+
+ ) +} diff --git a/distribution-admin/src/homeBanner.ts b/distribution-admin/src/homeBanner.ts new file mode 100644 index 0000000..d099232 --- /dev/null +++ b/distribution-admin/src/homeBanner.ts @@ -0,0 +1,254 @@ +/** + * 分销站点首页轮播配置。 + * 与主站 mobile.home_banners 共用 pc / mobile 双端结构,历史扁平数据在读取时自动升级。 + */ + +export const HOME_BANNER_PC_RATIO = { width: 1600, height: 400 } +export const HOME_BANNER_MOBILE_RATIO = { width: 750, height: 375 } + +export type BannerActionType = 'none' | 'route' | 'link' | 'preview' + +export interface BannerAction { + type: BannerActionType + target: string + open_new_tab: boolean +} + +export interface BannerHotspot { + id: string + label: string + x: number + y: number + width: number + height: number + image_url: string + hover_scale: number + action: BannerAction +} + +export interface BannerVisual { + image_url: string + alt: string + action: BannerAction + hotspots: BannerHotspot[] +} + +export interface DistributionBannerConfig { + eyebrow: string + title: string + badge: string + pill: string + tone: string + enabled: boolean + pc: BannerVisual + mobile: BannerVisual +} + +export const bannerActionOptions: { value: BannerActionType; label: string }[] = [ + { value: 'none', label: '不跳转' }, + { value: 'route', label: '站内路径' }, + { value: 'link', label: '外部链接' }, + { value: 'preview', label: '查看大图' }, +] + +export const bannerActionHints: Record = { + none: '点击无动作;未配置热区时仍可点击查看大图', + route: '站内路径,如 /listings、/m/announcements', + link: '外部链接,需以 http:// 或 https:// 开头', + preview: '点击放大查看原图', +} + +const ACTION_TYPES: BannerActionType[] = ['none', 'route', 'link', 'preview'] +const BANNER_TONES = ['blue', 'green', 'orange', 'gold'] +export const BANNER_HOTSPOT_DEFAULT_HOVER_SCALE = 1.06 +const BANNER_HOTSPOT_MAX_HOVER_SCALE = 1.5 + +export function createEmptyBannerAction(): BannerAction { + return { type: 'none', target: '', open_new_tab: false } +} + +export function createEmptyBannerVisual(): BannerVisual { + return { image_url: '', alt: '', action: createEmptyBannerAction(), hotspots: [] } +} + +export function createBannerHotspot(partial: Partial = {}): BannerHotspot { + return { + id: `hotspot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + label: '', + x: 6, + y: 20, + width: 24, + height: 24, + image_url: '', + hover_scale: BANNER_HOTSPOT_DEFAULT_HOVER_SCALE, + action: createEmptyBannerAction(), + ...partial, + } +} + +export function createEmptyDistributionBanner(): DistributionBannerConfig { + return { + eyebrow: '', + title: '', + badge: '本店精选', + pill: '', + tone: 'gold', + enabled: true, + pc: createEmptyBannerVisual(), + mobile: createEmptyBannerVisual(), + } +} + +export function normalizeBannerAction(raw: unknown): BannerAction { + const source = isRecord(raw) ? raw : {} + const rawType = typeof source.type === 'string' ? source.type.trim() : '' + const type = ACTION_TYPES.find(item => item === rawType) ?? 'none' + const target = text(source.target) + if ((type === 'route' || type === 'link') && !target) { + return createEmptyBannerAction() + } + return { type, target, open_new_tab: source.open_new_tab === true } +} + +export function normalizeBannerHotspot(raw: unknown, index: number): BannerHotspot | null { + if (!isRecord(raw)) return null + const width = clampPercent(raw.width) + const height = clampPercent(raw.height) + if (width <= 0 || height <= 0) return null + return { + id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `hotspot-${index + 1}`, + label: text(raw.label), + x: clampPercent(raw.x, 100 - width), + y: clampPercent(raw.y, 100 - height), + width, + height, + image_url: text(raw.image_url), + hover_scale: normalizeHotspotHoverScale(raw.hover_scale), + action: normalizeBannerAction(raw.action), + } +} + +export function normalizeBannerVisual( + raw: unknown, + legacy: { imageUrl?: string; action?: BannerAction } = {} +): BannerVisual { + const source = isRecord(raw) ? raw : {} + const imageUrl = text(source.image_url) || text(legacy.imageUrl) + const action = normalizeBannerAction(source.action) + const rawHotspots = Array.isArray(source.hotspots) ? source.hotspots : [] + return { + image_url: imageUrl, + alt: text(source.alt), + action: action.type === 'none' && legacy.action ? legacy.action : action, + hotspots: rawHotspots + .map((item, index) => normalizeBannerHotspot(item, index)) + .filter((item): item is BannerHotspot => item !== null), + } +} + +/** 读取后台 JSON:新结构原样归一化,旧扁平结构自动升级为两端共图。 */ +export function parseDistributionBanners( + raw: string | undefined, + siteName = '' +): DistributionBannerConfig[] { + const parsed = safeParseJSON(raw) + if (!Array.isArray(parsed)) return [] + return parsed.map(item => normalizeDistributionBanner(item, siteName)) +} + +export function normalizeDistributionBanner( + raw: unknown, + siteName = '' +): DistributionBannerConfig { + const source = isRecord(raw) ? raw : {} + const legacyImageUrl = text(source.image_url) + const legacyAction = resolveLegacyBannerAction(source.link_url || source.link) + const structured = isRecord(source.pc) || isRecord(source.mobile) + const sharedLegacy = { imageUrl: legacyImageUrl, action: legacyAction } + + const banner: DistributionBannerConfig = { + eyebrow: text(source.eyebrow), + title: text(source.title), + badge: text(source.badge), + pill: text(source.pill), + tone: normalizeBannerTone(source.tone), + enabled: source.enabled !== false, + pc: normalizeBannerVisual(source.pc, sharedLegacy), + mobile: normalizeBannerVisual(source.mobile, sharedLegacy), + } + + if (!structured) { + const hasImage = Boolean(legacyImageUrl) + banner.eyebrow = hasImage ? '' : text(source.eyebrow) || siteName + banner.title = hasImage ? '' : text(source.title) || '精选游戏账号 · 随租随玩' + banner.badge = text(source.badge) || '本店精选' + banner.pill = hasImage ? '' : text(source.pill) || '官方保障' + banner.tone = 'gold' + } else if (!banner.badge) { + banner.badge = '' + } + + return banner +} + +/** 保存前过滤完全空白的卡片,序列化为与主站一致的 JSON 结构。 */ +export function serializeDistributionBanners(banners: DistributionBannerConfig[]): string { + return JSON.stringify(banners.filter(hasBannerContent)) +} + +export function hasBannerContent(banner: DistributionBannerConfig): boolean { + return Boolean( + banner.eyebrow.trim() || + banner.title.trim() || + banner.pill.trim() || + banner.pc.image_url.trim() || + banner.mobile.image_url.trim() || + banner.pc.hotspots.length || + banner.mobile.hotspots.length + ) +} + +function resolveLegacyBannerAction(raw: unknown): BannerAction { + const target = text(raw) + if (!target) return createEmptyBannerAction() + if (/^https?:\/\//i.test(target)) return { type: 'link', target, open_new_tab: true } + if (target.startsWith('/')) return { type: 'route', target, open_new_tab: false } + return createEmptyBannerAction() +} + +function normalizeHotspotHoverScale(value: unknown): number { + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num) || num <= 0) return BANNER_HOTSPOT_DEFAULT_HOVER_SCALE + const clamped = Math.min(Math.max(num, 1), BANNER_HOTSPOT_MAX_HOVER_SCALE) + return Math.round(clamped * 100) / 100 +} + +function normalizeBannerTone(value: unknown): string { + const tone = text(value) + return BANNER_TONES.includes(tone) ? tone : 'gold' +} + +function clampPercent(value: unknown, max = 100): number { + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num)) return 0 + const clamped = Math.min(Math.max(num, 0), max) + return Math.round(clamped * 100) / 100 +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function safeParseJSON(raw: string | undefined): unknown { + const value = (raw || '').trim() + if (!value) return [] + try { + return JSON.parse(value) + } catch { + return [] + } +} diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx index a47eca7..183506a 100644 --- a/distribution-admin/src/pages/SiteSettings.tsx +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -50,12 +50,15 @@ import { type TestSiteSMSConfigPayload, } from '../api' import { useMember } from '../MemberContext' - -interface BannerItem { - image_url: string - title?: string - link?: string -} +import BannerVisualEditor from '../components/BannerVisualEditor' +import { + HOME_BANNER_MOBILE_RATIO, + HOME_BANNER_PC_RATIO, + createEmptyDistributionBanner, + parseDistributionBanners, + serializeDistributionBanners, + type DistributionBannerConfig, +} from '../homeBanner' interface AnnouncementItem { title: string @@ -134,7 +137,7 @@ export default function SiteSettings() { // Visual state for customizer const [logoUrl, setLogoUrl] = useState('') const [faviconUrl, setFaviconUrl] = useState('') - const [banners, setBanners] = useState([]) + const [banners, setBanners] = useState([]) const [announcements, setAnnouncements] = useState([]) const [storefrontText, setStorefrontText] = useState({ ...DEFAULT_STOREFRONT_TEXT, @@ -187,11 +190,7 @@ export default function SiteSettings() { setLogoUrl(found.logo_url || '') setFaviconUrl(found.favicon_url || '') - try { - setBanners(found.banners_json ? JSON.parse(found.banners_json) : []) - } catch { - setBanners([]) - } + setBanners(parseDistributionBanners(found.banners_json, found.site_name)) try { setAnnouncements(found.announcements_json ? JSON.parse(found.announcements_json) : []) @@ -277,16 +276,25 @@ export default function SiteSettings() { setBanners([ ...banners, { - image_url: '', - title: '热门推荐活动', - link: '', + ...createEmptyDistributionBanner(), + eyebrow: '首页推荐', }, ]) } - function handleUpdateBanner(index: number, field: keyof BannerItem, value: string) { + function handleUpdateBanner(index: number, patch: Partial) { + setBanners((current) => + current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)), + ) + } + + function handleMoveBanner(index: number, step: number) { + const target = index + step + if (target < 0 || target >= banners.length) return const next = [...banners] - next[index] = { ...next[index], [field]: value } + const [item] = next.splice(index, 1) + if (!item) return + next.splice(target, 0, item) setBanners(next) } @@ -516,7 +524,7 @@ export default function SiteSettings() { const payload: SiteBrandingPayload = { logo_url: logoUrl.trim(), favicon_url: faviconUrl.trim(), - banners_json: JSON.stringify(banners), + banners_json: serializeDistributionBanners(banners), announcements_json: JSON.stringify(announcements), footer_info_json: JSON.stringify(footerInfo), aw_recycle_json: awUseMain ? '' : JSON.stringify(awEntry), @@ -534,6 +542,10 @@ export default function SiteSettings() { } } + const previewBanner = banners.find((item) => item.enabled) ?? banners[0] + const previewBannerImage = + previewBanner?.mobile.image_url || previewBanner?.pc.image_url || '' + return (
@@ -839,12 +851,18 @@ export default function SiteSettings() { label: `首页轮播横幅 (${banners.length})`, children: ( +
- 为分销商城首页配置吸睛的促销横幅(支持自动轮播展示)。 + 为分销商城首页配置轮播海报,后台坐标为百分比,换分辨率也不会跑位。
@@ -857,72 +875,133 @@ export default function SiteSettings() {
-
- 横幅 #{idx + 1} - +
+ + 轮播 #{idx + 1} + handleUpdateBanner(idx, { enabled })} + /> + + + + + +
- - -
- {item.image_url ? ( - - ) : ( -
无图片
- )} -
+ + + + handleUpdateBanner(idx, { eyebrow: e.target.value })} + /> - - - - - uploadEntryImage(file, 'home-banner', (url) => - handleUpdateBanner(idx, 'image_url', url), - ) - } - > - - - - handleUpdateBanner(idx, 'image_url', e.target.value)} - /> - handleUpdateBanner(idx, 'title', e.target.value)} - /> - handleUpdateBanner(idx, 'link', e.target.value)} - /> - + + handleUpdateBanner(idx, { title: e.target.value })} + /> + + + handleUpdateBanner(idx, { badge: e.target.value })} + /> + + + handleUpdateBanner(idx, { pill: e.target.value })} + /> + + +