- 新增 PaymentCountdown、ProductImage 组件 - 完善 checkout/orders/orderDetail 订单支付链路与地址、商品、设计数据 - request 接口地址改为构建期注入并保留真实网络错误,默认兜底线上地址 - 同步重新构建 dist 产物
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
import { View, Text, Image } from '@tarojs/components'
|
||
import Taro from '@tarojs/taro'
|
||
import { useState, useEffect } from 'react'
|
||
import './index.scss'
|
||
import { getProductById } from '../../utils/productConfig'
|
||
import { getDesignList, setDesignList, updateDesign as updateLocalDesign, type DesignItem, type StickerItem } from '../../utils/store'
|
||
import { persistDesignMedia } from '../../utils/api'
|
||
import { createDesign, updateDesign as updateRemoteDesign } from '../../utils/api/design'
|
||
import { useThemeContext } from '../../context/ThemeContext'
|
||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||
import ThemedPageMeta from '../../components/ThemedPageMeta'
|
||
import ScrollTopMask from '../../components/ScrollTopMask'
|
||
import BottomActionBar from '../../components/BottomActionBar'
|
||
|
||
export default function DIYPage() {
|
||
const { theme, resolvedTheme } = useThemeContext()
|
||
const safe = useSafeArea()
|
||
useStatusBar(resolvedTheme)
|
||
const [category, setCategory] = useState<any>(null)
|
||
const [step, setStep] = useState(1)
|
||
const [designId, setDesignId] = useState('')
|
||
const [quantity, setQuantity] = useState(1)
|
||
const [stickers, setStickers] = useState<StickerItem[]>([])
|
||
const [activeStickerId, setActiveStickerId] = useState<string | null>(null)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
|
||
const [previewMode, setPreviewMode] = useState(false)
|
||
const [hasOverlap, setHasOverlap] = useState(false)
|
||
const [isCreatingDesign, setIsCreatingDesign] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const router = Taro.getCurrentInstance().router
|
||
const params = router ? router.params : undefined
|
||
const source = params ? params.source : undefined
|
||
const productId = (params && params.productId) || (params && params.category)
|
||
|
||
const createDraftForProduct = async (product: any, count: number) => {
|
||
const draft: DesignItem = {
|
||
id: 'DSG' + Date.now(),
|
||
productId: product.id,
|
||
productName: product.name,
|
||
productIcon: product.icon,
|
||
unitPrice: product.price,
|
||
count,
|
||
status: 'designing',
|
||
createdAt: new Date().toISOString().slice(0, 10),
|
||
}
|
||
|
||
// 先写本地草稿,保证离线时仍能编辑;在线时立即换成服务端真实 id。
|
||
setDesignList([...getDesignList(), draft])
|
||
setDesignId(draft.id)
|
||
setIsCreatingDesign(true)
|
||
try {
|
||
const saved = await createDesign({
|
||
productId: draft.productId,
|
||
productName: draft.productName,
|
||
unitPrice: draft.unitPrice,
|
||
count: draft.count,
|
||
designData: draft.designData,
|
||
})
|
||
setDesignList(getDesignList().map(item => item.id === draft.id ? saved : item))
|
||
setDesignId(saved.id)
|
||
} catch {
|
||
// 保留本地草稿;结算页会在网络恢复后继续走本地兜底。
|
||
} finally {
|
||
setIsCreatingDesign(false)
|
||
}
|
||
}
|
||
|
||
if (source === 'product' && productId) {
|
||
const product = getProductById(productId)
|
||
if (product) {
|
||
setCategory(product)
|
||
const count = Number(params ? params.quantity : undefined) || 1
|
||
setQuantity(count)
|
||
void createDraftForProduct(product, count)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (source === 'designList' && params && params.designId) {
|
||
const dId = params.designId
|
||
const list = getDesignList()
|
||
const design = list.find(d => d.id === dId)
|
||
if (design) {
|
||
setDesignId(dId)
|
||
setQuantity(design.count)
|
||
const product = getProductById(design.productId)
|
||
if (product) setCategory(product)
|
||
if (design.designData ? design.designData.stickers : undefined) {
|
||
setStickers(design.designData.stickers)
|
||
} else if (design.designData ? design.designData.imageSrc : undefined) {
|
||
// 兼容旧版数据
|
||
const s: StickerItem = {
|
||
id: 'legacy_' + Date.now(),
|
||
src: design.designData.imageSrc,
|
||
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0,
|
||
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0,
|
||
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1,
|
||
width: 200,
|
||
height: 200,
|
||
isOverlapping: false
|
||
}
|
||
setStickers([s])
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// 默认情况(从首页 old category 参数兼容)
|
||
if (productId) {
|
||
const found = getProductById(productId)
|
||
if (found) {
|
||
setCategory(found)
|
||
void createDraftForProduct(found, 1)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
// 矩形碰撞检测
|
||
const checkOverlap = (list: StickerItem[]) => {
|
||
const newList = list.map(s => ({ ...s, isOverlapping: false }))
|
||
for (let i = 0; i < newList.length; i++) {
|
||
for (let j = i + 1; j < newList.length; j++) {
|
||
const a = newList[i]
|
||
const b = newList[j]
|
||
const aw = a.width * a.scale
|
||
const ah = a.height * a.scale
|
||
const bw = b.width * b.scale
|
||
const bh = b.height * b.scale
|
||
if (
|
||
a.x < b.x + bw &&
|
||
a.x + aw > b.x &&
|
||
a.y < b.y + bh &&
|
||
a.y + ah > b.y
|
||
) {
|
||
newList[i].isOverlapping = true
|
||
newList[j].isOverlapping = true
|
||
}
|
||
}
|
||
}
|
||
const overlapAny = newList.some(s => s.isOverlapping)
|
||
setHasOverlap(overlapAny)
|
||
return newList
|
||
}
|
||
|
||
const addSticker = () => {
|
||
Taro.chooseImage({
|
||
count: 1,
|
||
sizeType: ['compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
success: (res) => {
|
||
const src = res.tempFilePaths[0]
|
||
// 获取图片尺寸用于碰撞检测
|
||
Taro.getImageInfo({
|
||
src,
|
||
success: (info) => {
|
||
const newSticker: StickerItem = {
|
||
id: 'stk_' + Date.now(),
|
||
src,
|
||
x: 0,
|
||
y: 0,
|
||
scale: 1,
|
||
width: info.width,
|
||
height: info.height,
|
||
isOverlapping: false
|
||
}
|
||
const next = [...stickers, newSticker]
|
||
const checked = checkOverlap(next)
|
||
setStickers(checked)
|
||
setActiveStickerId(newSticker.id)
|
||
},
|
||
fail: () => {
|
||
// fallback 尺寸
|
||
const newSticker: StickerItem = {
|
||
id: 'stk_' + Date.now(),
|
||
src,
|
||
x: 0,
|
||
y: 0,
|
||
scale: 1,
|
||
width: 200,
|
||
height: 200,
|
||
isOverlapping: false
|
||
}
|
||
const next = [...stickers, newSticker]
|
||
const checked = checkOverlap(next)
|
||
setStickers(checked)
|
||
setActiveStickerId(newSticker.id)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
const deleteSticker = (id: string) => {
|
||
const next = stickers.filter(s => s.id !== id)
|
||
const checked = checkOverlap(next)
|
||
setStickers(checked)
|
||
if (activeStickerId === id) setActiveStickerId(null)
|
||
}
|
||
|
||
const handleStickerTouchStart = (e: any, id: string) => {
|
||
const touch = e.touches[0]
|
||
const s = stickers.find(x => x.id === id)
|
||
if (!s) return
|
||
setActiveStickerId(id)
|
||
setIsDragging(true)
|
||
setStartPos({ x: touch.clientX - s.x, y: touch.clientY - s.y })
|
||
}
|
||
|
||
const handleStickerTouchMove = (e: any) => {
|
||
if (!isDragging || !activeStickerId) return
|
||
const touch = e.touches[0]
|
||
const next = stickers.map(s => {
|
||
if (s.id !== activeStickerId) return s
|
||
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
|
||
})
|
||
const checked = checkOverlap(next)
|
||
setStickers(checked)
|
||
}
|
||
|
||
const handleTouchEnd = () => setIsDragging(false)
|
||
|
||
const handleEditSticker = () => {
|
||
if (!activeStickerId || !designId) return
|
||
Taro.navigateTo({
|
||
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
|
||
})
|
||
}
|
||
|
||
const handleScale = (id: string, delta: number) => {
|
||
const next = stickers.map(s => {
|
||
if (s.id !== id) return s
|
||
return { ...s, scale: Math.max(0.3, Math.min(3, s.scale + delta)) }
|
||
})
|
||
const checked = checkOverlap(next)
|
||
setStickers(checked)
|
||
}
|
||
|
||
const goBack = () => {
|
||
Taro.navigateBack()
|
||
}
|
||
|
||
const getMaskStyle = () => {
|
||
if (!category) return { width: 300, height: 420 }
|
||
const base: any = { width: category.mask.width, height: category.mask.height }
|
||
if (category.mask.shape === 'rect') {
|
||
base.borderRadius = category.mask.borderRadius || 0
|
||
}
|
||
if (category.mask.shape === 'circle') {
|
||
base.borderRadius = '50%'
|
||
}
|
||
return base
|
||
}
|
||
|
||
const handleComplete = async () => {
|
||
if (isCreatingDesign) {
|
||
Taro.showToast({ title: '正在创建设计,请稍候', icon: 'none' })
|
||
return
|
||
}
|
||
if (hasOverlap) {
|
||
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
|
||
return
|
||
}
|
||
if (designId) {
|
||
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
|
||
const data = await persistDesignMedia({ stickers, category, version: 1 })
|
||
updateLocalDesign(designId, { designData: data })
|
||
try {
|
||
await updateRemoteDesign(designId, { designData: data, status: 'designing' })
|
||
} catch {
|
||
// 离线草稿继续由本地存储承接;线上草稿会在下一次操作时重试同步。
|
||
}
|
||
}
|
||
setPreviewMode(false)
|
||
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
|
||
}
|
||
|
||
const activeSticker = activeStickerId ? stickers.find(s => s.id === activeStickerId) : undefined
|
||
const activeScale = activeSticker ? activeSticker.scale : 1
|
||
|
||
if (!category) {
|
||
return (
|
||
<View className={`theme-${resolvedTheme}`}>
|
||
<ThemedPageMeta />
|
||
<ScrollTopMask title="设计工作台" targetSelector=".page-header.surface-card" showBack />
|
||
<View className='diy-page'>
|
||
<Text className='page-title'>加载中...</Text>
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<View className={`theme-${resolvedTheme}`}>
|
||
<ThemedPageMeta />
|
||
<ScrollTopMask title="设计工作台" targetSelector=".page-header.surface-card" showBack />
|
||
<View className='diy-page'>
|
||
|
||
<View className='page-header surface-card' style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
|
||
<View className='flex-between' style={{ width: '100%' }}>
|
||
<Text className='back-btn' onTap={goBack}>←</Text>
|
||
<Text className='page-title'>设计工作台</Text>
|
||
<View className='header-spacer' />
|
||
</View>
|
||
</View>
|
||
|
||
{/* 画布区域 — catchtouchmove 阻止事件冒泡导致页面滚动 */}
|
||
<View className='canvas-wrapper surface-card mt-20' catchMove>
|
||
<View className='canvas-area' style={getMaskStyle()}>
|
||
{stickers.map(s => (
|
||
<Image
|
||
key={s.id}
|
||
className={`canvas-image ${s.isOverlapping ? 'overlap' : ''} ${activeStickerId === s.id ? 'active' : ''}`}
|
||
src={s.src}
|
||
style={{
|
||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
|
||
zIndex: activeStickerId === s.id ? 10 : 5,
|
||
width: s.width || 200,
|
||
height: s.height || 200
|
||
}}
|
||
onTouchStart={(e) => handleStickerTouchStart(e, s.id)}
|
||
onTouchMove={handleStickerTouchMove}
|
||
onTouchEnd={handleTouchEnd}
|
||
onTap={() => setActiveStickerId(s.id)}
|
||
mode='aspectFit'
|
||
/>
|
||
))}
|
||
<View className='mask-border' style={getMaskStyle()} />
|
||
</View>
|
||
</View>
|
||
|
||
{/* 贴纸列表与控制 */}
|
||
<View className='sticker-panel surface-card mt-20'>
|
||
<Text className='section-title'>贴纸</Text>
|
||
<View className='sticker-list'>
|
||
{stickers.map(s => (
|
||
<View key={s.id} className={`sticker-thumb ${s.isOverlapping ? 'overlap' : ''} ${activeStickerId === s.id ? 'active' : ''}`}>
|
||
<Image src={s.src} className='sticker-thumb-img' mode='aspectFit' onTap={() => setActiveStickerId(s.id)} />
|
||
<View className='sticker-del' onTap={() => deleteSticker(s.id)}>
|
||
<Text className='del-icon'>×</Text>
|
||
</View>
|
||
</View>
|
||
))}
|
||
<View className='sticker-add-btn' onTap={addSticker}>
|
||
<Text className='add-icon'>+</Text>
|
||
<Text className='add-label'>添加</Text>
|
||
</View>
|
||
</View>
|
||
{hasOverlap && <Text className='overlap-hint'>有贴纸重叠,请调整位置</Text>}
|
||
</View>
|
||
|
||
{/* 控制工具栏 */}
|
||
{activeStickerId && (
|
||
<View className='toolbar surface-card mt-20'>
|
||
<View className='tool-row'>
|
||
<Text className='tool-label'>缩放</Text>
|
||
<View className='flex-center' style={{ gap: '20px' }}>
|
||
<View className='tool-btn' onTap={() => handleScale(activeStickerId, -0.1)}>-</View>
|
||
<Text className='tool-value'>{Math.round(activeScale * 100)}%</Text>
|
||
<View className='tool-btn' onTap={() => handleScale(activeStickerId, 0.1)}>+</View>
|
||
</View>
|
||
</View>
|
||
<View className='tool-row'>
|
||
<Text className='tool-label'>提示</Text>
|
||
<Text className='tool-hint'>拖动调整位置,贴纸不可重叠</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 常驻编辑按钮 */}
|
||
<View className='edit-btn-bar mt-20'>
|
||
<View
|
||
className={`edit-btn ${activeStickerId ? 'active' : 'disabled'}`}
|
||
onTap={handleEditSticker}
|
||
>
|
||
<Text>编辑</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 底部操作按钮 */}
|
||
<BottomActionBar>
|
||
<View className='btn-primary' onTap={() => setPreviewMode(true)}>
|
||
<Text>预览效果</Text>
|
||
</View>
|
||
<View className='btn-secondary' onTap={addSticker}>
|
||
<Text>添加贴纸</Text>
|
||
</View>
|
||
</BottomActionBar>
|
||
|
||
{/* 预览弹层 */}
|
||
{previewMode && (
|
||
<View className='preview-overlay'>
|
||
<View className='preview-card surface-card'>
|
||
<Text className='preview-title'>确认效果</Text>
|
||
<View className='preview-canvas' style={getMaskStyle()}>
|
||
{stickers.map(s => (
|
||
<Image
|
||
key={s.id}
|
||
className={`preview-image ${s.isOverlapping ? 'overlap' : ''}`}
|
||
src={s.src}
|
||
style={{
|
||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||
width: s.width || 200,
|
||
height: s.height || 200
|
||
}}
|
||
mode='aspectFit'
|
||
/>
|
||
))}
|
||
</View>
|
||
<View className='product-info'>
|
||
<Text className='product-name'>{category.name}</Text>
|
||
<Text className='product-size'>画布尺寸: {category.mask.width} × {category.mask.height} px</Text>
|
||
{hasOverlap && <Text className='overlap-hint'>贴纸有重叠,不可提交</Text>}
|
||
</View>
|
||
<View className='preview-actions'>
|
||
<View className='btn-secondary' onTap={() => setPreviewMode(false)}>
|
||
<Text>返回修改</Text>
|
||
</View>
|
||
<View className={`btn-primary ${hasOverlap ? 'disabled' : ''}`} onTap={handleComplete}>
|
||
<Text>确认完成</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
<View className='safe-bottom-placeholder' />
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|