feat(distribution-banner): 分销站点轮播支持 PC/移动端分图与热区配置

This commit is contained in:
yml2213
2026-10-17 14:47:00 +08:00
parent ab9d852bde
commit 790267e3eb
9 changed files with 1472 additions and 120 deletions
+3 -1
View File
@@ -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<string> {
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<ApiResponse<{ url: string }>>('/admin/files/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -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<HTMLDivElement | null>(null)
const valueRef = useRef(value)
const onChangeRef = useRef(onChange)
const dragRef = useRef<DragState | null>(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<BannerVisual>) {
onChangeRef.current({ ...valueRef.current, ...patch })
}
function updateHotspot(id: string, patch: Partial<BannerHotspot>) {
emit({
hotspots: valueRef.current.hotspots.map((item) =>
item.id === id ? { ...item, ...patch } : item,
),
})
}
function patchSelected(patch: Partial<BannerHotspot>) {
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<HTMLDivElement>) {
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<HTMLElement>,
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 (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<Space wrap>
<Typography.Text strong>跳转类型</Typography.Text>
<Select<BannerActionType>
value={action.type}
style={{ width: 130 }}
options={bannerActionOptions}
onChange={(type) => onActionChange({ ...action, type })}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{bannerActionHints[action.type]}
</Typography.Text>
</Space>
{needTarget && (
<>
<Input
value={action.target}
placeholder={action.type === 'link' ? 'https://example.com/act' : '/m/announcements'}
onChange={(event) => onActionChange({ ...action, target: event.target.value })}
/>
<Space>
<Switch
checked={action.open_new_tab}
onChange={(checked) => onActionChange({ ...action, open_new_tab: checked })}
/>
<Typography.Text type="secondary">在新窗口打开</Typography.Text>
</Space>
</>
)}
</Space>
)
}
return (
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<Typography.Text strong>{label}</Typography.Text>
<Typography.Text style={{ color: '#ff6a00', fontSize: 12, fontWeight: 700 }}>
建议出图 {standard}
</Typography.Text>
</div>
<Space.Compact style={{ width: '100%' }}>
<Input
value={value.image_url}
placeholder="图片 URL,留空则该端退回文字卡片"
onChange={(event) => {
setBannerWarning('')
emit({ image_url: event.target.value })
}}
/>
<Upload
accept="image/jpeg,image/png,image/webp"
showUploadList={false}
beforeUpload={(file) => {
void handleBannerUpload(file)
return false
}}
>
<Button icon={<UploadOutlined />} loading={uploading}>
上传图片
</Button>
</Upload>
<Button
disabled={!value.image_url}
onClick={() => {
setBannerWarning('')
setSelectedId('')
emit({ image_url: '' })
}}
>
清除图片
</Button>
</Space.Compact>
{bannerWarning && (
<Typography.Text style={{ color: '#d97706', fontSize: 12 }}>{bannerWarning}</Typography.Text>
)}
<div
ref={canvasRef}
onClick={addHotspotAt}
style={{
position: 'relative',
width: '100%',
aspectRatio: `${ratio.width} / ${ratio.height}`,
borderRadius: 10,
overflow: 'hidden',
cursor: 'crosshair',
userSelect: 'none',
background: 'repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 50% / 18px 18px',
}}
>
{value.image_url ? (
<img
src={value.image_url}
alt=""
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
pointerEvents: 'none',
}}
/>
) : (
<div
style={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
color: '#94a3b8',
fontSize: 13,
}}
>
先上传图片,再在图上单击新增热区
</div>
)}
{value.hotspots.map((hotspot, index) => (
<div
key={hotspot.id}
onClick={(event) => {
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 && (
<img
src={hotspot.image_url}
alt=""
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
opacity: 0.82,
pointerEvents: 'none',
}}
/>
)}
<span
style={{
position: 'relative',
zIndex: 1,
flex: '0 0 auto',
padding: '0 4px',
borderRadius: 4,
background: '#ff6a00',
fontWeight: 700,
}}
>
{index + 1}
</span>
<span
style={{
position: 'relative',
zIndex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textShadow: '0 1px 2px rgba(15, 23, 42, 0.6)',
}}
>
{hotspot.label || '未命名热区'}
</span>
<i
onMouseDown={(event) => 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',
}}
/>
</div>
))}
</div>
<Typography.Text type="secondary" style={{ fontSize: 12, lineHeight: 1.6 }}>
在预览图上单击可新增热区,按住拖动可移动,拖右下角小方块可缩放;坐标按百分比保存,换分辨率也不跑位。
热区按钮图必须是带透明通道的 PNG。
</Typography.Text>
<div
style={{
display: 'grid',
gap: 10,
padding: '10px 12px',
border: '1px solid #eef1f5',
borderRadius: 10,
background: '#fafbfc',
}}
>
{renderActionEditor(value.action, updateAction)}
<Input
value={value.alt}
placeholder="整图说明(无障碍描述,可选)"
onChange={(event) => emit({ alt: event.target.value })}
/>
</div>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Text strong>热区({value.hotspots.length})</Typography.Text>
<Button
size="small"
disabled={!value.hotspots.length}
onClick={() => {
emit({ hotspots: [] })
setSelectedId('')
setHotspotWarning('')
}}
>
清空热区
</Button>
</div>
{!value.hotspots.length ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
未配置热区时,整张图按上面的「整图点击」行为响应。
</Typography.Text>
) : (
<>
<Space wrap size={6}>
{value.hotspots.map((hotspot, index) => (
<Button
key={hotspot.id}
size="small"
type={hotspot.id === selectedId ? 'primary' : 'default'}
onClick={() => {
setSelectedId(hotspot.id)
setHotspotWarning('')
}}
>
{index + 1}. {hotspot.label || '未命名热区'}
</Button>
))}
</Space>
{selectedHotspot && (
<div
style={{
display: 'grid',
gap: 10,
padding: '10px 12px',
border: '1px solid #eef1f5',
borderRadius: 10,
background: '#fafbfc',
}}
>
<Input
value={selectedHotspot.label}
placeholder="热区名称,如 租币教程"
onChange={(event) => patchSelected({ label: event.target.value })}
/>
<Space.Compact style={{ width: '100%' }}>
<Input
value={selectedHotspot.image_url}
placeholder="透明 PNG 按钮图 URL,留空则用高亮框"
onChange={(event) => {
setHotspotWarning('')
patchSelected({ image_url: event.target.value })
}}
/>
<Upload
accept="image/png,image/webp"
showUploadList={false}
beforeUpload={(file) => {
void handleHotspotUpload(file)
return false
}}
>
<Button icon={<PictureOutlined />} loading={uploadingHotspot}>
上传按钮图
</Button>
</Upload>
<Button
disabled={!selectedHotspot.image_url}
onClick={() => {
setHotspotWarning('')
patchSelected({ image_url: '' })
}}
>
清除
</Button>
</Space.Compact>
{hotspotWarning && (
<Typography.Text style={{ color: '#d97706', fontSize: 12 }}>
{hotspotWarning}
</Typography.Text>
)}
<Space wrap>
<Typography.Text>悬浮放大</Typography.Text>
<InputNumber
min={1}
max={1.5}
step={0.02}
precision={2}
value={selectedHotspot.hover_scale}
onChange={(value) =>
patchSelected({
hover_scale: value ?? BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
})
}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
1 = 不放大,建议 1.04-1.08
</Typography.Text>
</Space>
<Space wrap size={8}>
<Typography.Text>位置尺寸</Typography.Text>
<InputNumber
min={0}
max={100}
step={0.5}
precision={1}
addonBefore="X"
value={selectedHotspot.x}
onChange={(value) => patchSelected({ x: value ?? 0 })}
/>
<InputNumber
min={0}
max={100}
step={0.5}
precision={1}
addonBefore="Y"
value={selectedHotspot.y}
onChange={(value) => patchSelected({ y: value ?? 0 })}
/>
<InputNumber
min={1}
max={100}
step={0.5}
precision={1}
addonBefore="宽"
value={selectedHotspot.width}
onChange={(value) => patchSelected({ width: value ?? 1 })}
/>
<InputNumber
min={1}
max={100}
step={0.5}
precision={1}
addonBefore="高"
value={selectedHotspot.height}
onChange={(value) => patchSelected({ height: value ?? 1 })}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
百分比(0-100)
</Typography.Text>
</Space>
{renderActionEditor(selectedHotspot.action, (action) =>
patchSelected({ action }),
)}
<div>
<Button danger size="small" icon={<DeleteOutlined />} onClick={removeHotspot}>
删除该热区
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
+254
View File
@@ -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<BannerActionType, string> = {
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> = {}): 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<string, unknown> {
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 []
}
}
+202 -99
View File
@@ -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<string>('')
const [faviconUrl, setFaviconUrl] = useState<string>('')
const [banners, setBanners] = useState<BannerItem[]>([])
const [banners, setBanners] = useState<DistributionBannerConfig[]>([])
const [announcements, setAnnouncements] = useState<AnnouncementItem[]>([])
const [storefrontText, setStorefrontText] = useState<StorefrontTextForm>({
...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<DistributionBannerConfig>) {
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 (
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -839,12 +851,18 @@ export default function SiteSettings() {
label: `首页轮播横幅 (${banners.length})`,
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="info"
showIcon
message="PC 与移动端分开配置"
description="出图规范:PC 端 1600×400(4:1)、移动端 750×375(2:1),两端容器严格等比。每端可单独上传图片、配置整图跳转和热区;某一端留空时,该端前台回退为文字卡片。"
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
为分销商城首页配置吸睛的促销横幅(支持自动轮播展示)。
为分销商城首页配置轮播海报,后台坐标为百分比,换分辨率也不会跑位。
</Typography.Text>
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddBanner}>
添加轮播横幅
添加轮播
</Button>
</div>
@@ -857,72 +875,133 @@ export default function SiteSettings() {
<div
key={idx}
style={{
background: '#fbfbfb',
border: '1px solid #e8e8e8',
borderRadius: 8,
padding: '12px 16px',
background: '#ffffff',
border: '1px solid #eef1f5',
borderRadius: 10,
padding: '12px 14px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<strong style={{ color: '#1677ff' }}>横幅 #{idx + 1}</strong>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteBanner(idx)}
>
删除
</Button>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 10,
}}
>
<Space>
<strong style={{ color: '#ff6a00' }}>轮播 #{idx + 1}</strong>
<Switch
size="small"
checked={item.enabled}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(enabled) => handleUpdateBanner(idx, { enabled })}
/>
</Space>
<Space size={4}>
<Button
size="small"
disabled={idx === 0}
onClick={() => handleMoveBanner(idx, -1)}
>
上移
</Button>
<Button
size="small"
disabled={idx === banners.length - 1}
onClick={() => handleMoveBanner(idx, 1)}
>
下移
</Button>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteBanner(idx)}
>
删除
</Button>
</Space>
</div>
<Row gutter={12}>
<Col span={6}>
<div style={{ height: 80, background: '#eee', borderRadius: 4, overflow: 'hidden' }}>
{item.image_url ? (
<Image
src={item.image_url}
width="100%"
height={80}
style={{ objectFit: 'cover' }}
/>
) : (
<div style={{ lineHeight: '80px', textAlign: 'center', color: '#aaa', fontSize: 12 }}>无图片</div>
)}
</div>
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col xs={24} sm={12}>
<Input
placeholder="眉标,如 三角洲行动账号专区"
value={item.eyebrow}
onChange={(e) => handleUpdateBanner(idx, { eyebrow: e.target.value })}
/>
</Col>
<Col span={18}>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) =>
uploadEntryImage(file, 'home-banner', (url) =>
handleUpdateBanner(idx, 'image_url', url),
)
}
>
<Button icon={<PictureOutlined />}>上传横幅</Button>
</Upload>
</Space>
<Input
placeholder="或粘贴图片链接 https://..."
value={item.image_url}
onChange={(e) => handleUpdateBanner(idx, 'image_url', e.target.value)}
/>
<Input
placeholder="横幅标题 (例如:特惠专区)"
value={item.title}
onChange={(e) => handleUpdateBanner(idx, 'title', e.target.value)}
/>
<Input
placeholder="跳转链接 (可选)"
value={item.link}
onChange={(e) => handleUpdateBanner(idx, 'link', e.target.value)}
/>
</Space>
<Col xs={24} sm={12}>
<Input
placeholder="主标题(无图时展示)"
value={item.title}
onChange={(e) => handleUpdateBanner(idx, { title: e.target.value })}
/>
</Col>
<Col xs={12} sm={6}>
<Input
placeholder="角标,如 HOT"
value={item.badge}
onChange={(e) => handleUpdateBanner(idx, { badge: e.target.value })}
/>
</Col>
<Col xs={12} sm={10}>
<Input
placeholder="胶囊文案(可选)"
value={item.pill}
onChange={(e) => handleUpdateBanner(idx, { pill: e.target.value })}
/>
</Col>
<Col xs={24} sm={8}>
<Select
style={{ width: '100%' }}
value={item.tone}
onChange={(tone) => handleUpdateBanner(idx, { tone })}
options={[
{ label: '金色', value: 'gold' },
{ label: '蓝色', value: 'blue' },
{ label: '绿色', value: 'green' },
{ label: '橙色', value: 'orange' },
]}
/>
</Col>
</Row>
<Tabs
size="small"
items={[
{
key: 'pc',
label: 'PC 端 1600×400(4:1)',
children: (
<BannerVisualEditor
value={item.pc}
onChange={(pc) => handleUpdateBanner(idx, { pc })}
ratio={HOME_BANNER_PC_RATIO}
label="PC 端展示"
standard="1600×400(4:1)"
/>
),
},
{
key: 'mobile',
label: '移动端 750×375(2:1)',
children: (
<BannerVisualEditor
value={item.mobile}
onChange={(mobile) => handleUpdateBanner(idx, { mobile })}
ratio={HOME_BANNER_MOBILE_RATIO}
label="移动端展示"
standard="750×375(2:1)"
/>
),
},
]}
/>
</div>
))
)}
@@ -1233,42 +1312,66 @@ export default function SiteSettings() {
)}
{/* Simulated Banner Carousel */}
<div style={{ height: 140, background: '#eee', position: 'relative', overflow: 'hidden' }}>
{banners.length > 0 && banners[0]?.image_url ? (
<div
style={{
aspectRatio: '2 / 1',
background: 'repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 50% / 16px 16px',
position: 'relative',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{previewBannerImage ? (
<img
src={banners[0].image_url}
src={previewBannerImage}
alt="Banner"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
) : previewBanner ? (
<div
style={{
width: '100%',
height: '100%',
padding: 16,
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
justifyContent: 'center',
color: '#999',
fontSize: 12,
background: 'linear-gradient(135deg, #1e293b, #334155)',
color: '#fff',
}}
>
暂无轮播横幅,请在左侧添加
{previewBanner.eyebrow && (
<span style={{ fontSize: 11, opacity: 0.8 }}>{previewBanner.eyebrow}</span>
)}
<strong style={{ fontSize: 16, marginTop: 4 }}>
{previewBanner.title || '精选游戏账号 · 随租随玩'}
</strong>
{previewBanner.pill && (
<span style={{ fontSize: 11, marginTop: 6, color: '#fbbf24' }}>
{previewBanner.pill}
</span>
)}
</div>
) : (
<div style={{ color: '#999', fontSize: 12 }}>暂无轮播横幅,请在左侧添加</div>
)}
{banners.length > 0 && banners[0]?.title && (
<div
{previewBanner?.badge && (
<span
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(0,0,0,0.5)',
top: 8,
right: 8,
padding: '1px 6px',
borderRadius: 4,
background: 'rgba(15, 23, 42, 0.72)',
color: '#fff',
padding: '4px 10px',
fontSize: 12,
fontSize: 10,
}}
>
{banners[0].title}
</div>
{previewBanner.badge}
</span>
)}
</div>
@@ -0,0 +1,106 @@
/**
* 图片透明区域探测:用于后台在上传「热区按钮图 / 海报底图」时提示
* 「这张图其实没有透明区域」——最常见的坑是把设计工具里带棋盘格的预览
* 截图/另存下来,棋盘格就成了真实像素,前台会照实显示。
*/
export interface ImageTransparencyProbe {
/** 是否存在透明像素(alpha < 250) */
hasTransparentPixels: boolean
/** 透明像素占比(0-1) */
transparentRatio: number
/** 不透明像素是否只有极少数浅灰/白颜色(棋盘格或纯色底烘焙进图片的典型特征) */
suspectedBakedBackdrop: boolean
}
const SAMPLE_MAX_SIDE = 160
/** alpha 低于该值即视为透明像素 */
const TRANSPARENT_ALPHA = 250
/** 无明显透明像素的判定阈值(透明像素占比) */
const NEGLIGIBLE_TRANSPARENT_RATIO = 0.002
/** 纯像素分析(无 DOM 依赖,便于单测)。 */
export function analyzeImagePixels(data: Uint8ClampedArray | number[]): ImageTransparencyProbe {
const total = Math.floor(data.length / 4)
if (total <= 0) {
return { hasTransparentPixels: false, transparentRatio: 0, suspectedBakedBackdrop: false }
}
let transparent = 0
let opaque = 0
let lightOnly = true
const colors = new Set<number>()
for (let index = 0; index < total; index += 1) {
const offset = index * 4
const r = data[offset] ?? 0
const g = data[offset + 1] ?? 0
const b = data[offset + 2] ?? 0
const a = data[offset + 3] ?? 0
if (a < TRANSPARENT_ALPHA) {
transparent += 1
continue
}
opaque += 1
const min = Math.min(r, g, b)
const max = Math.max(r, g, b)
if (min < 216 || max - min > 28) lightOnly = false
// 量化到 4bit/通道,避免抗锯齿噪点导致颜色数虚高
colors.add(((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4))
}
const transparentRatio = transparent / total
return {
hasTransparentPixels: transparentRatio > NEGLIGIBLE_TRANSPARENT_RATIO,
transparentRatio,
suspectedBakedBackdrop:
opaque > 0 &&
lightOnly &&
transparentRatio <= NEGLIGIBLE_TRANSPARENT_RATIO &&
colors.size <= 4,
}
}
/** 读取本地文件像素并分析;环境不支持时返回 null(不阻塞上传)。 */
export async function probeImageTransparency(file: File): Promise<ImageTransparencyProbe | null> {
const source = await loadImageSource(file)
if (!source) return null
const width = Math.max(1, Math.round(source.width))
const height = Math.max(1, Math.round(source.height))
const scale = Math.min(1, SAMPLE_MAX_SIDE / Math.max(width, height))
const targetWidth = Math.max(1, Math.round(width * scale))
const targetHeight = Math.max(1, Math.round(height * scale))
const canvas = document.createElement('canvas')
canvas.width = targetWidth
canvas.height = targetHeight
const context = canvas.getContext('2d')
if (!context) return null
context.clearRect(0, 0, targetWidth, targetHeight)
context.drawImage(source as CanvasImageSource, 0, 0, targetWidth, targetHeight)
const imageData = context.getImageData(0, 0, targetWidth, targetHeight)
return analyzeImagePixels(imageData.data)
}
async function loadImageSource(file: File): Promise<ImageBitmap | HTMLImageElement | null> {
if (typeof createImageBitmap === 'function') {
try {
return await createImageBitmap(file)
} catch {
// 继续走 <img> 兜底
}
}
return await new Promise(resolve => {
const url = URL.createObjectURL(file)
const image = new Image()
image.onload = () => {
URL.revokeObjectURL(url)
resolve(image)
}
image.onerror = () => {
URL.revokeObjectURL(url)
resolve(null)
}
image.src = url
})
}
+121
View File
@@ -0,0 +1,121 @@
import { probeImageTransparency } from './imageTransparency'
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
avatar: 512,
chat: 1280,
announcement: 1600,
'home-banner': 1920,
'aw-recycle': 1920,
'cooperation-feedback': 1920,
listing: 1920,
dispute: 1920,
handoff: 1920,
realname: 1920,
qrcode: 2560,
}
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
avatar: 0.82,
chat: 0.8,
announcement: 0.84,
'home-banner': 0.84,
'aw-recycle': 0.84,
'cooperation-feedback': 0.84,
listing: 0.84,
dispute: 0.86,
handoff: 0.86,
realname: 0.86,
qrcode: 0.95,
}
export interface UploadEncoding {
maxSide: number
quality: number
outputType: 'image/webp' | 'image/png'
extension: 'webp' | 'png'
}
/**
* 决定上传前的压缩参数。
*
* 关键点:带透明通道的图(例如热区透明按钮图、透明 Logo)必须输出 PNG,
* 否则压缩过程会把透明区域压成实色(历史上就是先铺白底再编码,导致透明按钮图变白底方块)。
*/
export function resolveUploadEncoding(scene: string, preserveAlpha: boolean): UploadEncoding {
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.82
return {
maxSide,
quality,
outputType: preserveAlpha ? 'image/png' : 'image/webp',
extension: preserveAlpha ? 'png' : 'webp',
}
}
export async function optimizeImageForUpload(file: File, scene: string) {
if (!file.type.startsWith('image/')) return file
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) return file
if (typeof document === 'undefined') return file
try {
// 先判断原图是否真的有透明像素:有则全程保留 alpha
const probe = await probeImageTransparency(file)
const preserveAlpha = Boolean(probe?.hasTransparentPixels)
const { maxSide, quality, outputType, extension } = resolveUploadEncoding(scene, preserveAlpha)
const image = await loadImage(file)
const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) return file
// 不铺白底:canvas 本身保留透明,输出 PNG / WebP 都能带 alpha
context.clearRect(0, 0, width, height)
context.drawImage(image, 0, 0, width, height)
const blob = await canvasToBlob(canvas, outputType, quality)
if (!blob || blob.size >= file.size) return file
return new File([blob], replaceFileExt(file.name, extension), {
type: outputType,
lastModified: Date.now(),
})
} catch {
return file
}
}
function loadImage(file: File) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(file)
const image = new Image()
image.onload = () => {
URL.revokeObjectURL(url)
resolve(image)
}
image.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('图片读取失败'))
}
image.src = url
})
}
function fitSize(width: number, height: number, maxSide: number) {
if (width <= 0 || height <= 0) return { width: 1, height: 1 }
const scale = Math.min(1, maxSide / Math.max(width, height))
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
return new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve, type, quality)
})
}
function replaceFileExt(filename: string, ext: string) {
const base = filename.replace(/\.[^.]+$/, '')
return `${base || 'image'}.${ext}`
}
+18 -5
View File
@@ -1,7 +1,7 @@
# 首页轮播图配置说明
> 适用范围:PC 首页 Hero 轮播(`HomeBanner.vue`)、移动端首页 Banner 轮播(`MobileHomeView.vue`)、后台「首页轮播图」弹窗(`HomeBannersDialog.vue`)。
> 配置存储位置:系统配置表 `mobile.home_banners`(JSON 字符串),前台接口 `GET /api/mobile-home-config`。
> 适用范围:PC 首页 Hero 轮播(`HomeBanner.vue`)、移动端首页 Banner 轮播(`MobileHomeView.vue`)、主站后台「首页轮播图」弹窗(`HomeBannersDialog.vue`),以及分销站点后台「品牌装修 → 首页轮播横幅」(`distribution-admin/src/pages/SiteSettings.tsx`)。
> 配置存储位置:主站为系统配置表 `mobile.home_banners`(JSON 字符串),前台接口 `GET /api/mobile-home-config`;分销站点为 `distribution_sites.banners_json`,随 `GET /api/distribution/site-info` 下发。
## 一、出图规范(两端容器严格等比,按规范出图不裁切)
@@ -107,9 +107,22 @@
3. 在预览图上**单击**新增热区 → **拖动**移动 → 拖右下角小方块缩放;下方可为选中热区设置名称、精确坐标、跳转,以及**热区按钮图**与**悬浮放大倍数**(后台预览图会把按钮图半透明铺在框内,所见即所得)。
4. 保存时写入 `mobile.home_banners`,后台会顺带把旧的扁平 `image_url` 结构升级为 `pc` / `mobile` 结构。
## 六、校验与兜底
## 六、分销站点后台
分销站点与主站使用**完全相同**的 `pc` / `mobile` 数据结构,但独立保存在 `distribution_sites.banners_json`,不影响主站配置。
入口:分销后台 → 品牌装修 → 「首页轮播横幅」。每条轮播同样支持:
1. 启用/停用、上移/下移、删除,以及眉标/主标题/角标/胶囊/色调;
2. PC 端与移动端分别上传图片、填写图片 URL、配置整图点击行为;
3. 每端独立配置热区:单击新增、拖动移动、拖右下角缩放,热区可设置透明 PNG 按钮图、悬浮放大、坐标与跳转;
4. 某一端留空时,该端前台回退为文字卡片,不会拿另一端的图强行裁切。
历史兼容:早期分销后台保存的扁平 `{ image_url, title, link }` 或 `{ image_url, link_url }` 会自动升级为两端共图;其中旧字段 `link` 与 `link_url` 都会被识别。分销前台归一化实现:`frontend/src/stores/distributionSite.ts`(`mapDistributionHomeBanners`)。
## 七、校验与兜底
- 前后端都会归一化配置:坐标收敛到 `0-100` 且不越界,宽高为 0 的热区直接丢弃;`route` / `link` 缺少目标时退化为 `none`(避免「点了没反应」的死热区)。
- 后端归一化实现:`backend/internal/modules/systemconfig/normalize.go`(`normalizeHomeBanners`)。
- 前端归一化实现:`frontend/src/features/listings/api/homeConfig.ts`(`normalizeHomeBanner`)。
- 相关测试:`frontend/src/features/listings/api/homeConfig.spec.ts`、`backend/internal/modules/systemconfig/home_banners_test.go`。
- 前端归一化实现:`frontend/src/features/listings/api/homeConfig.ts`(`normalizeHomeBanner`);分销站点为 `distribution-admin/src/homeBanner.ts`。
- 相关测试:`frontend/src/features/listings/api/homeConfig.spec.ts`、`frontend/src/stores/distributionHomeBanner.spec.ts`、`backend/internal/modules/systemconfig/home_banners_test.go`。
@@ -58,4 +58,71 @@ describe('mapDistributionHomeBanners', () => {
expect(slide?.pc.action.type).toBe('none')
expect(slide?.pc.hotspots).toEqual([])
})
it('supports pc and mobile images, copy and actions independently', () => {
const [slide] = mapDistributionHomeBanners(
[
{
eyebrow: '本店活动',
title: '周末特惠',
badge: 'NEW',
pill: '限时立减',
tone: 'orange',
enabled: true,
pc: {
image_url: '/pc-poster.webp',
alt: 'PC 海报',
action: { type: 'route', target: '/listings', open_new_tab: false },
hotspots: [
{
id: 'hotspot-pc',
label: '去选号',
x: 10,
y: 20,
width: 20,
height: 30,
image_url: '/button.png',
hover_scale: 1.06,
action: { type: 'link', target: 'https://example.com/act', open_new_tab: true },
},
],
},
mobile: {
image_url: '/mobile-poster.webp',
action: { type: 'preview', target: '', open_new_tab: false },
},
},
],
'天域商行'
)
expect(slide?.pc.image_url).toBe('/pc-poster.webp')
expect(slide?.mobile.image_url).toBe('/mobile-poster.webp')
expect(slide?.eyebrow).toBe('本店活动')
expect(slide?.title).toBe('周末特惠')
expect(slide?.tone).toBe('orange')
expect(slide?.pc.hotspots[0]?.label).toBe('去选号')
expect(slide?.mobile.hotspots).toEqual([])
})
it('still accepts the legacy link field used by the old distribution admin', () => {
const [slide] = mapDistributionHomeBanners(
[{ image_url: '/poster.png', link: '/m/announcements' }],
'天域商行'
)
expect(slide?.pc.action).toEqual({
type: 'route',
target: '/m/announcements',
open_new_tab: false,
})
})
it('allows one side to stay empty when the two ends are configured separately', () => {
const [slide] = mapDistributionHomeBanners(
[{ title: '本店精选', pc: { image_url: '/pc.webp' }, mobile: {} }],
'天域商行'
)
expect(slide?.pc.image_url).toBe('/pc.webp')
expect(slide?.mobile.image_url).toBe('')
})
})
+70 -15
View File
@@ -10,49 +10,104 @@ import {
import {
createEmptyBannerAction,
createEmptyBannerVisual,
normalizeBannerVisual,
type BannerAction,
type BannerVisual,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
/**
* 分站轮播数据。
* 新结构复用主站的 pc/mobile 视觉对象;image_url/link_url/link 仅用于兼容历史扁平配置。
*/
export interface DistributionBanner {
image_url: string
image_url?: string
link_url?: string
/** 早期分销后台保存的字段名,与前端读取的 link_url 不一致,这里一并兼容。 */
link?: string
title?: string
eyebrow?: string
badge?: string
pill?: string
tone?: string
enabled?: boolean
pc?: Partial<BannerVisual>
mobile?: Partial<BannerVisual>
}
/** 分站首页轮播:有图时不再叠标题,避免整幅压暗。 */
const DISTRIBUTION_BANNER_TONES = ['blue', 'green', 'orange', 'gold']
/** 分站首页轮播:新结构按两端分别出图,旧结构继续沿用原来的海报/文字卡片表现。 */
export function mapDistributionHomeBanners(
banners: DistributionBanner[],
siteName: string
): HomeBannerSlide[] {
return banners.map(banner => {
const imageUrl = banner.image_url?.trim() || ''
const action = resolveDistributionBannerAction(banner.link_url)
const visual: BannerVisual = { ...createEmptyBannerVisual(), image_url: imageUrl, action }
const legacyImageUrl = text(banner.image_url)
const legacyAction = resolveDistributionBannerAction(banner.link_url || banner.link)
const structured = Boolean(banner.pc || banner.mobile)
const fallbackVisual: BannerVisual = {
...createEmptyBannerVisual(),
image_url: legacyImageUrl,
action: legacyAction,
}
const pc = structured
? normalizeBannerVisual(banner.pc, legacyImageUrl)
: { ...fallbackVisual, hotspots: [] }
const mobile = structured
? normalizeBannerVisual(banner.mobile, legacyImageUrl)
: { ...fallbackVisual, hotspots: [] }
const legacyImage = Boolean(legacyImageUrl)
const hasImage = Boolean(pc.image_url || mobile.image_url)
const copy = structured
? {
eyebrow: text(banner.eyebrow),
title: text(banner.title),
pill: text(banner.pill),
}
: {
eyebrow: legacyImage ? '' : siteName,
title: legacyImage ? '' : text(banner.title) || '精选游戏账号 · 随租随玩',
pill: legacyImage ? '' : '官方保障',
}
// 双端都没配图和文案时仍保留一张可读的文字卡片,避免前台出现空白轮播。
if (structured && !hasImage && !copy.eyebrow && !copy.title && !copy.pill) {
copy.eyebrow = siteName
copy.title = '精选游戏账号 · 随租随玩'
copy.pill = '官方保障'
}
return {
eyebrow: imageUrl ? '' : siteName,
title: imageUrl ? '' : banner.title?.trim() || '精选游戏账号 · 随租随玩',
badge: '本店精选',
pill: imageUrl ? '' : '官方保障',
tone: 'gold',
enabled: true,
// 分站装修只配一张图,两端共用,避免移动端丢图
pc: { ...visual, hotspots: [] },
mobile: { ...visual, hotspots: [] },
eyebrow: copy.eyebrow,
title: copy.title,
badge: text(banner.badge) || (structured ? '' : '本店精选'),
pill: copy.pill,
tone: structured ? normalizeBannerTone(banner.tone) : 'gold',
enabled: banner.enabled !== false,
pc,
mobile,
}
})
}
/** 分站轮播的 link_url:绝对地址走外链新窗口,站内路径走路由。 */
function resolveDistributionBannerAction(linkUrl?: string): BannerAction {
const target = linkUrl?.trim() || ''
const target = text(linkUrl)
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 text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeBannerTone(value: unknown): string {
const tone = text(value)
return DISTRIBUTION_BANNER_TONES.includes(tone) ? tone : 'gold'
}
/** 分站首页主标题:站名与主站联名。主站或站名已含主站名时保持原样。 */
export function formatDistributionCoBrandName(siteName: string, isDistribution: boolean): string {
const name = siteName.trim()