import { useEffect, useRef, useState } from 'react' import { Button, Input, InputNumber, Select, Space, Switch, Typography, Upload, message, } from 'antd' import { DeleteOutlined, PictureOutlined, UploadOutlined } from '@ant-design/icons' import { BANNER_HOTSPOT_DEFAULT_HOVER_SCALE, bannerActionHints, bannerActionOptions, createBannerHotspot, type BannerAction, type BannerActionType, type BannerHotspot, type BannerVisual, } from '../homeBanner' import { uploadSiteImage } from '../api' import { probeImageTransparency } from '../utils/imageTransparency' interface Props { value: BannerVisual onChange: (value: BannerVisual) => void ratio: { width: number; height: number } label: string standard: string } type DragMode = 'move' | 'resize' interface DragState { id: string mode: DragMode clientX: number clientY: number origin: BannerHotspot } function clamp(value: number, min: number, max: number) { return Math.round(Math.min(Math.max(value, min), Math.max(max, min)) * 100) / 100 } export default function BannerVisualEditor({ value, onChange, ratio, label, standard }: Props) { const [uploading, setUploading] = useState(false) const [uploadingHotspot, setUploadingHotspot] = useState(false) const [bannerWarning, setBannerWarning] = useState('') const [hotspotWarning, setHotspotWarning] = useState('') const [selectedId, setSelectedId] = useState('') const canvasRef = useRef(null) const valueRef = useRef(value) const onChangeRef = useRef(onChange) const dragRef = useRef(null) const draggingRef = useRef(false) valueRef.current = value onChangeRef.current = onChange const selectedHotspot = value.hotspots.find((item) => item.id === selectedId) ?? null function emit(patch: Partial) { onChangeRef.current({ ...valueRef.current, ...patch }) } function updateHotspot(id: string, patch: Partial) { emit({ hotspots: valueRef.current.hotspots.map((item) => item.id === id ? { ...item, ...patch } : item, ), }) } function patchSelected(patch: Partial) { const target = valueRef.current.hotspots.find((item) => item.id === selectedId) if (!target) return updateHotspot(target.id, patch) } async function handleBannerUpload(file: File) { setUploading(true) try { const url = await uploadSiteImage(file, 'home-banner') emit({ image_url: url }) message.success('图片已上传') await warnAboutBannerImage(file) } catch { message.error('图片上传失败') } finally { setUploading(false) } } async function warnAboutBannerImage(file: File) { setBannerWarning('') const probe = await probeImageTransparency(file) if (!probe?.suspectedBakedBackdrop) return setBannerWarning( '这张图几乎只有白/浅灰两种颜色且没有透明区域,很像把「透明棋盘格」一起导出了,前台会照实显示。', ) message.warning('图片疑似带棋盘格底纹,请检查后重新导出') } async function handleHotspotUpload(file: File) { const target = valueRef.current.hotspots.find((item) => item.id === selectedId) if (!target) return setUploadingHotspot(true) try { const url = await uploadSiteImage(file, 'home-banner') updateHotspot(target.id, { image_url: url }) message.success('热区图片已上传') await warnAboutHotspotImage(file) } catch { message.error('热区图片上传失败') } finally { setUploadingHotspot(false) } } async function warnAboutHotspotImage(file: File) { setHotspotWarning('') const probe = await probeImageTransparency(file) if (!probe || probe.hasTransparentPixels) return setHotspotWarning( probe.suspectedBakedBackdrop ? '这张图没有透明区域,且只有白/浅灰底色:很可能是把「透明棋盘格」一起导出了。请重新导出带透明通道的 PNG。' : '这张图没有透明区域,会整块盖住海报背景;透明按钮图请用带 alpha 通道的 PNG。', ) message.warning('热区图片不带透明区域,请确认导出方式') } function addHotspotAt(event: React.MouseEvent) { if (draggingRef.current) return const canvas = canvasRef.current if (!canvas) return const rect = canvas.getBoundingClientRect() if (!rect.width || !rect.height) return const width = 24 const height = 24 const x = ((event.clientX - rect.left) / rect.width) * 100 const y = ((event.clientY - rect.top) / rect.height) * 100 const hotspot = createBannerHotspot({ x: clamp(x - width / 2, 0, 100 - width), y: clamp(y - height / 2, 0, 100 - height), width, height, }) emit({ hotspots: [...valueRef.current.hotspots, hotspot] }) setSelectedId(hotspot.id) setHotspotWarning('') } function startDrag( event: React.MouseEvent, hotspot: BannerHotspot, mode: DragMode, ) { event.preventDefault() event.stopPropagation() setSelectedId(hotspot.id) dragRef.current = { id: hotspot.id, mode, clientX: event.clientX, clientY: event.clientY, origin: { ...hotspot }, } window.addEventListener('mousemove', handleDragMove) window.addEventListener('mouseup', stopDrag) } function handleDragMove(event: MouseEvent) { const state = dragRef.current const canvas = canvasRef.current if (!state || !canvas) return const rect = canvas.getBoundingClientRect() if (!rect.width || !rect.height) return const deltaX = ((event.clientX - state.clientX) / rect.width) * 100 const deltaY = ((event.clientY - state.clientY) / rect.height) * 100 const target = valueRef.current.hotspots.find((item) => item.id === state.id) if (!target) return if (state.mode === 'move') { updateHotspot(state.id, { x: clamp(state.origin.x + deltaX, 0, 100 - target.width), y: clamp(state.origin.y + deltaY, 0, 100 - target.height), }) return } updateHotspot(state.id, { width: clamp(state.origin.width + deltaX, 4, 100 - target.x), height: clamp(state.origin.height + deltaY, 4, 100 - target.y), }) } function stopDrag() { if (!dragRef.current) return draggingRef.current = true window.removeEventListener('mousemove', handleDragMove) window.removeEventListener('mouseup', stopDrag) dragRef.current = null window.setTimeout(() => { draggingRef.current = false }, 0) } useEffect(() => { return () => { window.removeEventListener('mousemove', handleDragMove) window.removeEventListener('mouseup', stopDrag) } }, []) function removeHotspot() { if (!selectedHotspot) return emit({ hotspots: value.hotspots.filter((item) => item.id !== selectedHotspot.id) }) setSelectedId('') } function updateAction(action: BannerAction) { emit({ action }) } function renderActionEditor(action: BannerAction, onActionChange: (next: BannerAction) => void) { const needTarget = action.type === 'route' || action.type === 'link' return ( 跳转类型 value={action.type} style={{ width: 130 }} options={bannerActionOptions} onChange={(type) => onActionChange({ ...action, type })} /> {bannerActionHints[action.type]} {needTarget && ( <> onActionChange({ ...action, target: event.target.value })} /> onActionChange({ ...action, open_new_tab: checked })} /> 在新窗口打开 )} ) } return (
{label} 建议出图 {standard}
{ setBannerWarning('') emit({ image_url: event.target.value }) }} /> { void handleBannerUpload(file) return false }} > {bannerWarning && ( {bannerWarning} )}
{value.image_url ? ( ) : (
先上传图片,再在图上单击新增热区
)} {value.hotspots.map((hotspot, index) => (
{ event.stopPropagation() setSelectedId(hotspot.id) setHotspotWarning('') }} onMouseDown={(event) => startDrag(event, hotspot, 'move')} style={{ position: 'absolute', left: `${hotspot.x}%`, top: `${hotspot.y}%`, width: `${hotspot.width}%`, height: `${hotspot.height}%`, display: 'flex', alignItems: 'flex-start', gap: 4, padding: '2px 4px', border: `1px ${hotspot.id === selectedId ? 'solid' : 'dashed'} rgba(255, 106, 0, 0.85)`, borderRadius: 6, background: hotspot.id === selectedId ? 'rgba(255, 106, 0, 0.32)' : 'rgba(255, 106, 0, 0.16)', color: '#ffffff', fontSize: 11, lineHeight: 1.4, cursor: 'move', overflow: 'hidden', }} > {hotspot.image_url && ( )} {index + 1} {hotspot.label || '未命名热区'} startDrag(event, hotspot, 'resize')} style={{ position: 'absolute', right: 0, bottom: 0, width: 12, height: 12, borderRight: '2px solid #ff6a00', borderBottom: '2px solid #ff6a00', background: 'rgba(255, 255, 255, 0.9)', cursor: 'nwse-resize', }} />
))}
在预览图上单击可新增热区,按住拖动可移动,拖右下角小方块可缩放;坐标按百分比保存,换分辨率也不跑位。 热区按钮图必须是带透明通道的 PNG。
{renderActionEditor(value.action, updateAction)} emit({ alt: event.target.value })} />
热区({value.hotspots.length})
{!value.hotspots.length ? ( 未配置热区时,整张图按上面的「整图点击」行为响应。 ) : ( <> {value.hotspots.map((hotspot, index) => ( ))} {selectedHotspot && (
patchSelected({ label: event.target.value })} /> { setHotspotWarning('') patchSelected({ image_url: event.target.value }) }} /> { void handleHotspotUpload(file) return false }} > {hotspotWarning && ( {hotspotWarning} )} 悬浮放大 patchSelected({ hover_scale: value ?? BANNER_HOTSPOT_DEFAULT_HOVER_SCALE, }) } /> 1 = 不放大,建议 1.04-1.08 位置尺寸 patchSelected({ x: value ?? 0 })} /> patchSelected({ y: value ?? 0 })} /> patchSelected({ width: value ?? 1 })} /> patchSelected({ height: value ?? 1 })} /> 百分比(0-100) {renderActionEditor(selectedHotspot.action, (action) => patchSelected({ action }), )}
)} )}
) }