import { View, Text, Image } from '@tarojs/components' import Taro from '@tarojs/taro' import { useState, useEffect, useRef } from 'react' import './index.scss' import { getProductById } from '../../utils/productConfig' import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store' import { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api' 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(null) const [step, setStep] = useState(1) const [designId, setDesignId] = useState('') const [quantity, setQuantity] = useState(1) const [stickers, setStickers] = useState([]) const [activeStickerId, setActiveStickerId] = useState(null) const [isDragging, setIsDragging] = useState(false) const [startPos, setStartPos] = useState({ x: 0, y: 0 }) const [previewMode, setPreviewMode] = useState(false) const [hasOverlap, setHasOverlap] = useState(false) /** 进入 DIY 即创建清单条目,并推进到「设计中」(问题3 修正:进入工作台即 SUBMITTED),失败降级本地 */ const createDesignEntry = (product: { id: string; name: string; price: number; icon: string }, count: number) => { createDesign({ productId: product.id, productName: product.name, unitPrice: product.price, count }) .then(item => { setDesignList([item, ...getDesignList()]) // 回写缓存,checkout 等页面仍读缓存 setDesignId(item.id) return updateDesignApi(item.id, { status: 'designing' }) .then(updated => { setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: updated.status } : d)) }) .catch(() => { setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: 'designing' as const } : d)) }) }) .catch(() => { const newDesign: 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) } setDesignList([...getDesignList(), newDesign]) setDesignId(newDesign.id) }) } 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) if (source === 'product' && productId) { const product = getProductById(productId) if (product) { setCategory(product) const count = Number(params ? params.quantity : undefined) || 1 setQuantity(count) createDesignEntry(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) const dd = design.designData const rawStickers = dd ? dd.stickers : undefined if (rawStickers) { // 问题1 留意点:旧数据 width/height 为图片原始像素语义,读取端按 mask 等比归一化 //(契约实现注记,不做存量迁移);新语义数据(≤mask 短边 60%)原样通过 const mask = (dd && dd.category && dd.category.mask) || (product ? product.mask : undefined) const normalized: StickerItem[] = mask ? rawStickers.map(s => { if (s.width > mask.width || s.height > mask.height) { const k = (Math.min(mask.width, mask.height) * 0.6) / Math.max(s.width, s.height) return { ...s, width: Math.round(s.width * k), height: Math.round(s.height * k) } } return s }) : rawStickers setStickers(normalized) } else if (dd ? dd.imageSrc : undefined) { // 兼容旧版数据 const s: StickerItem = { id: 'legacy_' + Date.now(), src: dd.imageSrc, x: dd.imagePos ? dd.imagePos.x || 0 : 0, y: dd.imagePos ? dd.imagePos.y || 0 : 0, scale: dd.imagePos ? dd.imagePos.scale || 1 : 1, width: 200, height: 200, isOverlapping: false } setStickers([s]) } // 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing) if (design.status === 'undesigned') { updateDesign(dId, { status: 'designing' }) updateDesignApi(dId, { status: 'designing' }).catch(() => {}) } } return } // 默认情况(从首页 old category 参数兼容) if (productId) { const found = getProductById(productId) if (found) { setCategory(found) createDesignEntry(found, 1) } } }, []) // ---------- 草稿持久化(修正工作流 问题2)---------- // 贴纸变化防抖 800ms:本地缓存即时同步 + 服务端静默同步;不推状态(SUBMITTED 只由显式动作触发) const draftTimerRef = useRef | null>(null) const latestRef = useRef({ designId: '', quantity: 1, category: null as any, stickers: [] as StickerItem[] }) latestRef.current = { designId, quantity, category, stickers } const saveDraft = () => { const { designId: dId, quantity: qty, category: cat, stickers: st } = latestRef.current if (!dId || !cat) return // WCD 红线:基于缓存中服务端最新 designData 合并(保留 wordcloud 分组),只覆盖本页编辑字段 const cached = getDesignList().find(d => d.id === dId) const designData = { ...(cached && cached.designData ? cached.designData : {}), version: 1 as const, category: { id: cat.id, mask: cat.mask, ...(cat.tone ? { tone: cat.tone } : {}) }, stickers: st } updateDesign(dId, { designData }) // 本地缓存即时同步(问题4:贴纸编辑页从这里读取) updateDesignApi(dId, { item: { productId: cat.id, productName: cat.name, unitPrice: cat.price, count: qty, designData } }).catch(() => {}) // 草稿静默降级:失败仅落缓存 } const flushDraft = () => { if (draftTimerRef.current) { clearTimeout(draftTimerRef.current) draftTimerRef.current = null } saveDraft() } // 隐藏页面(切后台/跳转)时强制落盘,防止防抖窗口内的改动丢失 Taro.useDidHide(flushDraft) // 卸载时同样落盘 useEffect(() => flushDraft, []) // eslint-disable-next-line react-hooks/exhaustive-deps const skipDraftRef = useRef(true) useEffect(() => { if (skipDraftRef.current) { skipDraftRef.current = false; return } if (draftTimerRef.current) clearTimeout(draftTimerRef.current) draftTimerRef.current = setTimeout(saveDraft, 800) // eslint-disable-next-line react-hooks/exhaustive-deps }, [stickers]) // 问题5 留意点:页面栈返回(贴纸编辑页保存后 navigateBack)不触发重新挂载, // 需在 onShow 从缓存同步最新贴纸(src/edits),否则工作台仍渲染旧 state。 // 首次 onShow 跳过——mount 读取端已做过旧数据归一化,避免被未归一化的缓存覆盖。 const skipShowRef = useRef(true) Taro.useDidShow(() => { if (skipShowRef.current) { skipShowRef.current = false; return } const dId = latestRef.current.designId if (!dId) return const cached = getDesignList().find(d => d.id === dId) const list = cached && cached.designData ? cached.designData.stickers : undefined if (list && list.length) { setStickers(checkOverlap(list)) } }) // 矩形碰撞检测 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) => { // 问题1 修正:归一化到画布坐标空间(width/height = 画布显示像素,契约实现注记) const maskMin = Math.min(category.mask.width, category.mask.height) const k = (maskMin * 0.6) / Math.max(info.width, info.height) const newSticker: StickerItem = { id: 'stk_' + Date.now(), src, x: 0, y: 0, scale: 1, width: Math.round(info.width * k), height: Math.round(info.height * k), isOverlapping: false } const next = [...stickers, newSticker] const checked = checkOverlap(next) setStickers(checked) setActiveStickerId(newSticker.id) }, fail: () => { // fallback 尺寸(同样按画布空间归一化) const maskMin = Math.min(category.mask.width, category.mask.height) const k = (maskMin * 0.6) / 200 const newSticker: StickerItem = { id: 'stk_' + Date.now(), src, x: 0, y: 0, scale: 1, width: Math.round(200 * k), height: Math.round(200 * k), 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] // 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制 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 flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸 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) } // 问题5 修正:渲染端消费贴纸 edits(亮度/色相/对比度 → CSS filter), // 与贴纸编辑页同一呈现逻辑;checkout 预览消费 edits 记入 R3 待办 const stickerFilter = (s: StickerItem) => { const ed = s.edits if (!ed) return undefined const b = ed.brightness || 0 const h = ed.hue || 0 const c = ed.contrast || 0 if (b === 0 && h === 0 && c === 0) return undefined return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)` } 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 (hasOverlap) { Taro.showToast({ title: '贴纸不能重叠', icon: 'none' }) return } if (designId && category) { // 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材 const data = await persistDesignMedia({ stickers, category, version: 1 }) // category 裁剪为 {id, mask, tone}(阶段0 决策#2;契约约束#3:mask 必须保留) const trimmedCategory = { id: category.id, mask: category.mask, ...(category.tone ? { tone: category.tone } : {}) } // WCD 红线:基于服务端已有 designData 合并(保留 R4 写入的 wordcloud 分组等),只覆盖本页编辑的字段 const cached = getDesignList().find(d => d.id === designId) const designData = { ...(cached && cached.designData ? cached.designData : {}), version: 1 as const, category: trimmedCategory, stickers: data.stickers || stickers } // 本地缓存同步(checkout 仍读缓存) updateDesign(designId, { status: 'designing', designData }) try { await updateDesignApi(designId, { status: 'designing', item: { productId: category.id, productName: category.name, unitPrice: category.price, count: quantity, designData } }) } catch { Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }) } } 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 ( 加载中... ) } return ( 设计工作台 {/* 画布区域 — catchtouchmove 阻止事件冒泡导致页面滚动 */} {stickers.map(s => ( handleStickerTouchStart(e, s.id)} onTouchMove={handleStickerTouchMove} onTouchEnd={handleTouchEnd} onTap={() => setActiveStickerId(s.id)} mode='aspectFit' /> ))} {/* 贴纸列表与控制 */} 贴纸 {stickers.map(s => ( setActiveStickerId(s.id)} /> deleteSticker(s.id)}> × ))} + 添加 {hasOverlap && 有贴纸重叠,请调整位置} {/* 控制工具栏 */} {activeStickerId && ( 缩放 handleScale(activeStickerId, -0.1)}>- {Math.round(activeScale * 100)}% handleScale(activeStickerId, 0.1)}>+ 提示 拖动调整位置,贴纸不可重叠 )} {/* 常驻编辑按钮 */} 编辑 {/* 底部操作按钮 */} setPreviewMode(true)}> 预览效果 添加贴纸 {/* 预览弹层 */} {previewMode && ( 确认效果 {stickers.map(s => ( ))} {category.name} 画布尺寸: {category.mask.width} × {category.mask.height} px {hasOverlap && 贴纸有重叠,不可提交} setPreviewMode(false)}> 返回修改 确认完成 )} ) }