chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
@@ -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>
)
}