feat(distribution-banner): 分销站点轮播支持 PC/移动端分图与热区配置
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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,14 +875,46 @@ 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>
|
||||
<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
|
||||
@@ -874,55 +924,84 @@ export default function SiteSettings() {
|
||||
>
|
||||
删除
|
||||
</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' }}
|
||||
|
||||
<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 })}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ lineHeight: '80px', textAlign: 'center', color: '#aaa', fontSize: 12 }}>无图片</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
placeholder="或粘贴图片链接 https://..."
|
||||
value={item.image_url}
|
||||
onChange={(e) => handleUpdateBanner(idx, 'image_url', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="横幅标题 (例如:特惠专区)"
|
||||
placeholder="主标题(无图时展示)"
|
||||
value={item.title}
|
||||
onChange={(e) => handleUpdateBanner(idx, 'title', e.target.value)}
|
||||
onChange={(e) => handleUpdateBanner(idx, { title: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Input
|
||||
placeholder="跳转链接 (可选)"
|
||||
value={item.link}
|
||||
onChange={(e) => handleUpdateBanner(idx, 'link', e.target.value)}
|
||||
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' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</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 ? (
|
||||
<img
|
||||
src={banners[0].image_url}
|
||||
alt="Banner"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
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',
|
||||
color: '#999',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
暂无轮播横幅,请在左侧添加
|
||||
</div>
|
||||
)}
|
||||
{banners.length > 0 && banners[0]?.title && (
|
||||
{previewBannerImage ? (
|
||||
<img
|
||||
src={previewBannerImage}
|
||||
alt="Banner"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : previewBanner ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #1e293b, #334155)',
|
||||
color: '#fff',
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{banners[0].title}
|
||||
{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>
|
||||
)}
|
||||
{previewBanner?.badge && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'rgba(15, 23, 42, 0.72)',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user